dsh-ssh-workbench 1.6.4 → 1.7.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.
package/lib/core/guard.js CHANGED
@@ -1,25 +1,27 @@
1
1
  /**
2
- * Fallback blacklist for remote bash.
2
+ * Fallback blacklist for remote commands.
3
3
  *
4
4
  * Registered through `ctx.tools.guard()`: synchronous, monotonic, and checked
5
5
  * after DSH's own pre-execute policy and approval, so no later listener can
6
- * turn a denial back into a permission. It only inspects commands that will
7
- * actually run on a managed server (the session has an active target and the
8
- * tool is a POSIX remote shell: the native `bash` tool on POSIX Hosts, the
9
- * plugin-owned `remote_bash` tool on Windows Hosts); local bash, file tools and
10
- * every other tool are untouched.
6
+ * turn a denial back into a permission.
7
+ *
8
+ * There is no session-scoped target state: every remote tool names its own
9
+ * `server`, so the guard resolves that explicit reference. The native local
10
+ * `bash`/`pwsh` tools are never inspected — local work is outside this
11
+ * plugin's remote-command boundary.
11
12
  */
12
13
  import { classifyCommand } from './gate.js'
13
14
  import { classifyPowerShellCommand } from './gate-pwsh.js'
15
+ import { findServerRef } from './server-ref.js'
14
16
 
15
- /** The plugin-owned POSIX shell tool (Windows Hosts have no native bash). */
16
- export const REMOTE_BASH_TOOL_NAME = 'remote_bash'
17
+ /** The plugin-owned POSIX shell tool. */
18
+ const REMOTE_BASH_TOOL_NAME = 'remote_bash'
17
19
 
18
20
  /** The plugin-owned PowerShell tool (Windows targets). */
19
- export const REMOTE_PWSH_TOOL_NAME = 'remote_pwsh'
21
+ const REMOTE_PWSH_TOOL_NAME = 'remote_pwsh'
20
22
 
21
- /** Tools whose command argument runs in a POSIX shell on a managed target. */
22
- const POSIX_REMOTE_TOOL_NAMES = new Set(['bash', REMOTE_BASH_TOOL_NAME])
23
+ /** Explicit-target batch tool: every task names its own server. */
24
+ const REMOTE_EXEC_TOOL_NAME = 'remote_exec'
23
25
 
24
26
  export const BLOCK_REASON_PREFIX = 'dsh-ssh-workbench blocked this command: '
25
27
 
@@ -31,38 +33,88 @@ export function sessionKeyOf(exec) {
31
33
  return typeof id === 'string' && id.length > 0 ? id : undefined
32
34
  }
33
35
 
36
+ /**
37
+ * Pick the gate for one explicit server. A record's `platform` is only a hint:
38
+ * the executor probes the real dialect before running. When facts are not
39
+ * collected yet, run both blacklists so a stale hint cannot hide a
40
+ * Windows-only destructive command (and vice versa).
41
+ */
42
+ function classifyForServer(server, command) {
43
+ const dialect = server?.facts?.dialect
44
+ if (dialect === 'windows') return classifyPowerShellCommand(command)
45
+ if (dialect === 'posix') return classifyCommand(command)
46
+ const posix = classifyCommand(command)
47
+ if (posix.decision === 'deny') return posix
48
+ return classifyPowerShellCommand(command)
49
+ }
50
+
51
+ function summaryOf(prefix, command) {
52
+ const text = `${prefix}${command}`.slice(0, 200)
53
+ return text
54
+ }
55
+
34
56
  /**
35
57
  * @param {object} deps
36
- * @param {object} deps.targets per-session target store
37
58
  * @param {(entry: object) => void} [deps.recordActivity]
59
+ * @param {() => object[]} [deps.loadInventory]
38
60
  * @returns {(exec: object) => string|undefined} a `ctx.tools.guard()` handler
39
61
  */
40
- export function createBlacklistGuard({ targets, recordActivity } = {}) {
62
+ export function createBlacklistGuard({ recordActivity, loadInventory } = {}) {
63
+ function inventory() {
64
+ if (typeof loadInventory !== 'function') return undefined
65
+ try {
66
+ const servers = loadInventory()
67
+ return Array.isArray(servers) ? servers : undefined
68
+ } catch {
69
+ return undefined
70
+ }
71
+ }
72
+
73
+ function guardOne(exec, serverRef, command, label = '') {
74
+ const ref = typeof serverRef === 'string' ? serverRef.trim() : ''
75
+ if (!ref || ref.toLowerCase() === 'local') return undefined
76
+ if (!command.trim()) return undefined
77
+ const servers = inventory()
78
+ if (servers === undefined) return undefined
79
+ const server = findServerRef(servers, ref)
80
+ if (server === undefined) return undefined
81
+ const classification = classifyForServer(server, command)
82
+ if (classification.decision !== 'deny') return undefined
83
+ try {
84
+ recordActivity?.({
85
+ kind: 'exec',
86
+ server: server.name,
87
+ sessionId: sessionKeyOf(exec),
88
+ summary: summaryOf(label, command),
89
+ outcome: `blocked: ${classification.ruleId}`,
90
+ })
91
+ } catch {
92
+ // Activity recording is best effort and must never change the decision.
93
+ }
94
+ return `${BLOCK_REASON_PREFIX}${label}${classification.reason}`
95
+ }
96
+
97
+ /** Check every task independently; one denied task denies the whole batch. */
98
+ function guardBatch(exec) {
99
+ const tasks = exec.arguments?.tasks
100
+ if (!Array.isArray(tasks) || tasks.length === 0) return undefined
101
+ for (let index = 0; index < tasks.length; index += 1) {
102
+ const task = tasks[index]
103
+ if (!task || typeof task !== 'object') continue
104
+ const command = typeof task.command === 'string' ? task.command : ''
105
+ const denied = guardOne(exec, task.server, command, `task ${index + 1}: `)
106
+ if (denied !== undefined) return denied
107
+ }
108
+ return undefined
109
+ }
110
+
41
111
  return function blacklistGuard(exec) {
42
112
  try {
43
113
  if (!exec) return undefined
44
- const isPosix = POSIX_REMOTE_TOOL_NAMES.has(exec.name)
45
- const isPowerShell = exec.name === REMOTE_PWSH_TOOL_NAME
46
- if (!isPosix && !isPowerShell) return undefined
47
- const key = sessionKeyOf(exec)
48
- if (key === undefined || !targets) return undefined
49
- const binding = targets.get(key)
50
- if (!binding || !binding.active) return undefined
114
+ if (exec.name === REMOTE_EXEC_TOOL_NAME) return guardBatch(exec)
115
+ if (exec.name !== REMOTE_BASH_TOOL_NAME && exec.name !== REMOTE_PWSH_TOOL_NAME) return undefined
51
116
  const command = typeof exec.arguments?.command === 'string' ? exec.arguments.command : ''
52
- if (!command.trim()) return undefined
53
- const classification = isPowerShell ? classifyPowerShellCommand(command) : classifyCommand(command)
54
- if (classification.decision !== 'deny') return undefined
55
- try {
56
- recordActivity?.({
57
- kind: 'exec',
58
- server: binding.active,
59
- summary: command.slice(0, 200),
60
- outcome: `blocked: ${classification.ruleId}`,
61
- })
62
- } catch {
63
- // Activity recording is best effort and must never change the decision.
64
- }
65
- return `${BLOCK_REASON_PREFIX}${classification.reason}`
117
+ return guardOne(exec, exec.arguments?.server, command)
66
118
  } catch {
67
119
  // A guard must never turn its own failure into a denial of unrelated tools.
68
120
  return undefined
@@ -0,0 +1,101 @@
1
+ /**
2
+ * In-flight remote executions, for the composer's "current target" indicator.
3
+ *
4
+ * This is a display-only projection. It never selects a target, never reaches
5
+ * a tool, and disappears as soon as the dispatch settles. Every remote tool is
6
+ * explicit-server, so this map is the only place where the UI can learn what a
7
+ * running tool is touching.
8
+ */
9
+ import { sessionKeyOf } from './remote-shell-tool.js'
10
+ import {
11
+ REMOTE_EDIT_TOOL_NAME,
12
+ REMOTE_GLOB_TOOL_NAME,
13
+ REMOTE_GREP_TOOL_NAME,
14
+ REMOTE_READ_TOOL_NAME,
15
+ REMOTE_WRITE_TOOL_NAME,
16
+ } from './remote-file-tools.js'
17
+ import { REMOTE_EXEC_TOOL_NAME } from './remote-exec-tool.js'
18
+
19
+ const SINGLE_SERVER_TOOLS = new Set([
20
+ 'remote_bash',
21
+ 'remote_pwsh',
22
+ REMOTE_READ_TOOL_NAME,
23
+ REMOTE_WRITE_TOOL_NAME,
24
+ REMOTE_EDIT_TOOL_NAME,
25
+ REMOTE_GLOB_TOOL_NAME,
26
+ REMOTE_GREP_TOOL_NAME,
27
+ ])
28
+
29
+ /** Extract the explicit server names a remote tool call will touch. */
30
+ export function serversOf(exec) {
31
+ const name = exec?.name
32
+ const args = exec?.arguments
33
+ if (!name || !args || typeof args !== 'object') return []
34
+ if (name === REMOTE_EXEC_TOOL_NAME) {
35
+ const tasks = Array.isArray(args.tasks) ? args.tasks : []
36
+ const names = []
37
+ for (const task of tasks) {
38
+ if (task && typeof task.server === 'string' && task.server.length > 0 && !names.includes(task.server)) {
39
+ names.push(task.server)
40
+ }
41
+ }
42
+ return names
43
+ }
44
+ if (!SINGLE_SERVER_TOOLS.has(name)) return []
45
+ return typeof args.server === 'string' && args.server.length > 0 ? [args.server] : []
46
+ }
47
+
48
+ /** @param {{ now?: () => number }} [options] */
49
+ export function createLiveExecutions(options = {}) {
50
+ const now = options.now ?? Date.now
51
+ let nextToken = 0
52
+ /** @type {Map<number, { sessionId: string, tool: string, servers: string[], startedAt: number }>} */
53
+ const active = new Map()
54
+
55
+ /** Register one dispatch; returns undefined when this is not a remote call. */
56
+ function begin(exec) {
57
+ const servers = serversOf(exec)
58
+ if (servers.length === 0) return undefined
59
+ const sessionId = sessionKeyOf(exec)
60
+ if (!sessionId) return undefined
61
+ const token = ++nextToken
62
+ active.set(token, {
63
+ sessionId,
64
+ tool: typeof exec.name === 'string' ? exec.name : '',
65
+ servers,
66
+ startedAt: now(),
67
+ })
68
+ return token
69
+ }
70
+
71
+ function end(token) {
72
+ if (token !== undefined) active.delete(token)
73
+ }
74
+
75
+ /** @returns {{ count: number, servers: { name: string, count: number, startedAt: number }[] }} */
76
+ function snapshot(sessionId) {
77
+ const byName = new Map()
78
+ let calls = 0
79
+ for (const entry of active.values()) {
80
+ if (entry.sessionId !== sessionId) continue
81
+ calls += 1
82
+ for (const name of entry.servers) {
83
+ const previous = byName.get(name)
84
+ byName.set(name, {
85
+ name,
86
+ count: (previous?.count ?? 0) + 1,
87
+ startedAt: previous ? Math.min(previous.startedAt, entry.startedAt) : entry.startedAt,
88
+ })
89
+ }
90
+ }
91
+ return { count: calls, servers: [...byName.values()] }
92
+ }
93
+
94
+ function clear(sessionId) {
95
+ for (const [token, entry] of active) {
96
+ if (sessionId === undefined || entry.sessionId === sessionId) active.delete(token)
97
+ }
98
+ }
99
+
100
+ return { begin, end, snapshot, clear, size: () => active.size }
101
+ }
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Per-turn managed-resource context.
3
+ *
4
+ * The composer inserts an `@name` chip as plain text. This module treats it as a
5
+ * hint, not an execution binding: every remote tool call names its own server.
6
+ * This module injects one compact inventory + tool-rules message per human
7
+ * turn, so the model never has to read `servers.json` or switch a hidden
8
+ * session target before acting.
9
+ *
10
+ * The message enters the same step (`agent/pre-step` replaces the messages that
11
+ * enter a step), which `agent.inject()` cannot promise because it splices into
12
+ * the next step.
13
+ *
14
+ * Scope: every agent mode; no preset gating.
15
+ */
16
+ import { randomUUID } from 'node:crypto'
17
+
18
+ import { effectiveDialect } from './dialect.js'
19
+ import { loadServers } from './store.js'
20
+ import {
21
+ TARGET_CONTEXT_KIND,
22
+ createTargetStore,
23
+ extractServerMentions,
24
+ extractUserText,
25
+ lastHumanMessage,
26
+ } from './mentions.js'
27
+
28
+ export { TARGET_CONTEXT_KIND }
29
+
30
+ const DEFAULT_MAX_CONTEXT_SERVERS = 20
31
+
32
+ function describeError(error) {
33
+ return error instanceof Error ? error.message : String(error)
34
+ }
35
+
36
+ function serverLine(server) {
37
+ const target = `${server.username ?? 'root'}@${server.host}:${server.port ?? 22}`
38
+ const state = server.enabled === false ? ' · disabled' : ''
39
+ const dialect = effectiveDialect(server)
40
+ return `- ${server.name} — ${target} · ${dialect}${state}${factsSummary(server)}`
41
+ }
42
+
43
+ /** Compact facts summary (hostname only: stable, not a stale capacity snapshot). */
44
+ function factsSummary(server) {
45
+ const facts = server?.facts
46
+ if (!facts || typeof facts !== 'object') return ''
47
+ const hostname = typeof facts.hostname === 'string' ? facts.hostname.trim() : ''
48
+ return hostname ? ` · ${hostname}` : ''
49
+ }
50
+
51
+ function localShellName(hostPlatform) {
52
+ return hostPlatform === 'win32' ? 'pwsh' : 'bash'
53
+ }
54
+
55
+ /**
56
+ * Render the model-facing managed-resource context.
57
+ *
58
+ * @param {object[]} servers managed inventory
59
+ * @param {{
60
+ * candidates?: string[],
61
+ * lastExecuted?: string|null,
62
+ * hostPlatform?: 'win32'|'posix',
63
+ * maxServers?: number,
64
+ * }} [hints]
65
+ */
66
+ export function renderManagedContext(servers, hints = {}) {
67
+ const inventory = Array.isArray(servers) ? servers.filter(Boolean) : []
68
+ if (inventory.length === 0) return ''
69
+ const maxServers = Number.isInteger(hints.maxServers) && hints.maxServers > 0
70
+ ? hints.maxServers
71
+ : DEFAULT_MAX_CONTEXT_SERVERS
72
+ const listed = inventory.slice(0, maxServers)
73
+ const lines = [`[ssh-workbench] Managed remote resources (${inventory.length}):`]
74
+ for (const server of listed) lines.push(serverLine(server))
75
+ if (inventory.length > listed.length) {
76
+ lines.push(`- … and ${inventory.length - listed.length} more; call remote_exec with a resource name from the workbench UI.`)
77
+ }
78
+ lines.push('Remote execution rules:')
79
+ lines.push(
80
+ `- The native ${localShellName(hints.hostPlatform)} tool always runs on the local DSH host. Never use it for commands meant for a remote resource.`,
81
+ )
82
+ lines.push(
83
+ '- POSIX resources: remote_bash({ server: "<name>", command: "<command>", workdir?, timeoutMs?, run_in_background? }).',
84
+ )
85
+ lines.push(
86
+ '- Windows resources: remote_pwsh({ server: "<name>", command: "<command>", ... }).',
87
+ )
88
+ lines.push(
89
+ '- Several resources in one step: remote_exec({ tasks: [{ server: "<name>", command: "<command>" }], concurrency? }). Tasks run concurrently and never depend on a remembered target.',
90
+ )
91
+ lines.push(
92
+ '- Pass server explicitly on every call. The resource names above are authoritative; never read ~/.dsh/ssh-workbench/servers.json or other config files to find them.',
93
+ )
94
+ lines.push(
95
+ '- read/write/edit/glob/grep are the native local file tools; they never touch a remote resource.',
96
+ )
97
+ lines.push(
98
+ '- Remote files: remote_read({ server, path, maxBytes? }), remote_glob({ server, pattern, path?, maxResults?, maxDepth? }) and remote_grep({ server, pattern, path?, ignoreCase?, maxResults? }). Use absolute paths or paths under ~.',
99
+ )
100
+ if (hints.remoteFileWrite === true) {
101
+ lines.push(
102
+ '- Remote writes are enabled: remote_write({ server, path, content }) and remote_edit({ server, path, oldText, newText, replaceAll? }). remote_edit requires one exact match unless replaceAll=true.',
103
+ )
104
+ } else {
105
+ lines.push(
106
+ '- Remote writes are disabled by config: remote_write/remote_edit will refuse. Use the workbench SFTP panel for uploads, or ask the user to enable remoteFileWrite.',
107
+ )
108
+ }
109
+ const candidates = Array.isArray(hints.candidates) ? hints.candidates.filter(Boolean) : []
110
+ if (candidates.length > 0) {
111
+ lines.push(
112
+ `- The user mentioned: ${candidates.join(', ')}. Prefer these resources when the request is ambiguous, but still pass server explicitly.`,
113
+ )
114
+ }
115
+ const lastExecuted = typeof hints.lastExecuted === 'string' && hints.lastExecuted.length > 0
116
+ ? hints.lastExecuted
117
+ : null
118
+ if (lastExecuted) lines.push(`- Last remote execution in this conversation: ${lastExecuted}.`)
119
+ lines.push(
120
+ '- If no resource is named and the request does not identify one, ask the user or use the resource named in the request; do not guess silently.',
121
+ )
122
+ return lines.join('\n')
123
+ }
124
+
125
+ /**
126
+ * Build one user-role context message, preferring the host's message factory.
127
+ * `@` symbols are avoided so this message can never be re-matched as a mention.
128
+ */
129
+ export function buildManagedContextMessage(servers, hints, createUserMessage) {
130
+ const text = renderManagedContext(servers, hints)
131
+ const source = {
132
+ kind: TARGET_CONTEXT_KIND,
133
+ form: 'notice',
134
+ summary: 'managed remote resources',
135
+ }
136
+ const input = { content: [{ type: 'text', text }], source }
137
+ if (typeof createUserMessage === 'function') return createUserMessage(input)
138
+ return { role: 'user', id: `ssh-workbench-context-${randomUUID()}`, ...structuredClone(input) }
139
+ }
140
+
141
+ /**
142
+ * Install the resource-context provider.
143
+ *
144
+ * @param {object} ctx plugin context
145
+ * @param {{
146
+ * enabled?: boolean,
147
+ * remoteAvailable?: boolean,
148
+ * hostPlatform?: 'win32'|'posix',
149
+ * targets?: object,
150
+ * loadInventory?: () => object[],
151
+ * loadLastExecuted?: (sessionId: string) => string|null,
152
+ * createUserMessage?: Function,
153
+ * }} [options]
154
+ */
155
+ export function installManagedContext(ctx, options = {}) {
156
+ const enabled = options.enabled !== false
157
+ const remoteAvailable = options.remoteAvailable !== false
158
+ const loadInventory = options.loadInventory ?? loadServers
159
+ const targets = options.targets ?? createTargetStore({ loadInventory })
160
+ const maxContextServers = Number.isInteger(options.maxContextServers) && options.maxContextServers > 0
161
+ ? options.maxContextServers
162
+ : DEFAULT_MAX_CONTEXT_SERVERS
163
+ const warn = (message) => {
164
+ try {
165
+ ctx?.logger?.warn?.(`dsh-ssh-workbench: ${message}`)
166
+ } catch {
167
+ // Logging must never fail the plugin.
168
+ }
169
+ }
170
+ if (!ctx || typeof ctx.on !== 'function') return { targets }
171
+
172
+ let factory = options.createUserMessage
173
+ const messageFactory = async () => {
174
+ if (factory !== undefined) return factory
175
+ try {
176
+ const mod = await import('@deepseek-ai/dsh-llm')
177
+ factory = typeof mod.createUserMessage === 'function' ? mod.createUserMessage : null
178
+ } catch {
179
+ factory = null
180
+ }
181
+ return factory
182
+ }
183
+
184
+ const handler = async (payload, next) => {
185
+ const decision = await next()
186
+ try {
187
+ if (!decision || decision.kind === 'reject' || payload?.signal?.aborted) return decision
188
+ if (enabled === false) return decision
189
+ // Never describe a remote world the Host cannot execute in.
190
+ if (!remoteAvailable) return decision
191
+ const agent = payload?.agent
192
+ const session = agent?.session
193
+ const key = session?.id ?? agent?.id
194
+ if (typeof key !== 'string' || key.length === 0) return decision
195
+ let servers
196
+ try {
197
+ servers = loadInventory()
198
+ } catch (error) {
199
+ warn(`inventory unavailable: ${describeError(error)}`)
200
+ return decision
201
+ }
202
+ if (!Array.isArray(servers) || servers.length === 0) return decision
203
+ // Restore resumed-Session mention hints once; deriveMessages() is
204
+ // intentionally skipped on later steps.
205
+ if (targets.get(key)?.seeded !== true) {
206
+ try {
207
+ const history = typeof session?.deriveMessages === 'function' ? session.deriveMessages() : []
208
+ targets.ensureSeeded(key, history, servers)
209
+ } catch {
210
+ targets.ensureSeeded(key, [], servers)
211
+ }
212
+ }
213
+ const human = lastHumanMessage(decision.messages)
214
+ const mentions = human ? extractServerMentions(extractUserText(human), servers) : []
215
+ const state = targets.bindMentions(key, mentions, human?.id)
216
+ if (!state) return decision
217
+ const changed = state.version !== state.injectedVersion
218
+ const firstStep = payload?.step === 1
219
+ if (!changed) {
220
+ if (!firstStep) return decision
221
+ // One context message per turn is enough while it is still present in
222
+ // the step messages. Re-inject after compaction or a new human turn.
223
+ const hasContext = decision.messages.some((message) => message?.source?.kind === TARGET_CONTEXT_KIND)
224
+ if (hasContext) return decision
225
+ }
226
+ let lastExecuted = null
227
+ try {
228
+ lastExecuted = typeof options.loadLastExecuted === 'function' ? options.loadLastExecuted(key) : null
229
+ } catch {
230
+ lastExecuted = null
231
+ }
232
+ const hints = {
233
+ candidates: state.candidates,
234
+ lastExecuted: typeof lastExecuted === 'string' && lastExecuted.length > 0 ? lastExecuted : null,
235
+ hostPlatform: options.hostPlatform,
236
+ maxServers: maxContextServers,
237
+ }
238
+ const message = buildManagedContextMessage(servers, hints, await messageFactory())
239
+ targets.markInjected(key, state.version)
240
+ return { ...decision, messages: [...decision.messages, message] }
241
+ } catch (error) {
242
+ warn(`resource context skipped: ${describeError(error)}`)
243
+ return decision
244
+ }
245
+ }
246
+
247
+ const offPreStep = ctx.on('agent/pre-step', handler, { prepend: true })
248
+ const offDisposed = ctx.on('agent/disposed', (disposed) => {
249
+ try {
250
+ const session = disposed?.agent?.session
251
+ const key = session?.id ?? disposed?.agent?.id
252
+ if (typeof key === 'string') targets.clear(key)
253
+ } catch {
254
+ // Best effort cleanup.
255
+ }
256
+ })
257
+ if (typeof ctx.effect === 'function') {
258
+ ctx.effect(
259
+ () => () => {
260
+ try {
261
+ offPreStep?.()
262
+ } catch {
263
+ // Disposal is best effort.
264
+ }
265
+ try {
266
+ offDisposed?.()
267
+ } catch {
268
+ // Disposal is best effort.
269
+ }
270
+ targets.clearAll?.()
271
+ },
272
+ 'dsh-ssh-workbench: resource context',
273
+ )
274
+ }
275
+ return { targets, offPreStep, offDisposed }
276
+ }