dsh-ssh-workbench 1.6.4 → 1.7.8
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/CHANGELOG.md +133 -1
- package/CONTRIBUTING.md +5 -4
- package/README.md +47 -25
- package/SECURITY.md +2 -0
- package/docs/architecture.md +60 -38
- package/docs/execution-model.md +83 -0
- package/docs/images/chat-at-resource.png +0 -0
- package/docs/images/plugin.png +0 -0
- package/docs/images/remote-workbench.png +0 -0
- package/docs/images/workbench.png +0 -0
- package/docs/quickstart.md +24 -9
- package/lib/client.js +1249 -218
- package/lib/core/activity.js +5 -0
- package/lib/core/background.js +18 -4
- package/lib/core/broker.js +10 -7
- package/lib/core/dialect.js +1 -1
- package/lib/core/execution-stats.js +88 -0
- package/lib/core/gate-pwsh.js +1 -1
- package/lib/core/guard.js +86 -34
- package/lib/core/live-executions.js +101 -0
- package/lib/core/managed-context.js +287 -0
- package/lib/core/{targets.js → mentions.js} +49 -83
- package/lib/core/posix-shell.js +89 -0
- package/lib/core/prefs.js +36 -3
- package/lib/core/remote-exec-tool.js +441 -0
- package/lib/core/remote-file-tools.js +642 -0
- package/lib/core/{remote-bash-tool.js → remote-shell-tool.js} +58 -69
- package/lib/core/secrets.js +0 -1
- package/lib/core/server-ref.js +61 -0
- package/lib/core/shell-adapters.js +1 -1
- package/lib/core/ssh.js +22 -0
- package/lib/core/win32-adapter.js +1 -1
- package/lib/index.js +121 -157
- package/lib/routes.js +45 -5
- package/package.json +23 -7
- package/lib/core/remote-bash.js +0 -337
- package/lib/core/server-reference.js +0 -299
package/lib/core/activity.js
CHANGED
|
@@ -63,6 +63,11 @@ export function createActivityStore({ file = activityPath(), maxEntries = MAX_AC
|
|
|
63
63
|
...(entry?.server !== undefined ? { server: String(entry.server) } : {}),
|
|
64
64
|
...(entry?.summary !== undefined ? { summary: String(entry.summary) } : {}),
|
|
65
65
|
...(entry?.outcome !== undefined ? { outcome: String(entry.outcome) } : {}),
|
|
66
|
+
// Session/duration let the composer dock summarize this session's remote
|
|
67
|
+
// executions without reading DSH's private session metrics. Absent on
|
|
68
|
+
// older entries; readers must tolerate that.
|
|
69
|
+
...(typeof entry?.sessionId === 'string' && entry.sessionId.length > 0 ? { sessionId: entry.sessionId } : {}),
|
|
70
|
+
...(Number.isFinite(entry?.durationMs) && entry.durationMs >= 0 ? { durationMs: Math.round(entry.durationMs) } : {}),
|
|
66
71
|
}
|
|
67
72
|
const next = [normalized, ...load()].slice(0, maxEntries)
|
|
68
73
|
save(next)
|
package/lib/core/background.js
CHANGED
|
@@ -39,10 +39,17 @@ export function parseBackgroundStart(stdout) {
|
|
|
39
39
|
* `nohup` + redirected stdio is what keeps the child alive after the SSH
|
|
40
40
|
* channel closes; the whole launcher runs under `sh -c` so a fish/csh login
|
|
41
41
|
* shell only has to parse the simple outer command.
|
|
42
|
+
*
|
|
43
|
+
* Logs default to `~/.dsh/ssh-workbench/logs` instead of `/tmp`, because a
|
|
44
|
+
* detached process may outlive the tmp cleanup window; `DSH_SSH_WORKBENCH_LOG_DIR`
|
|
45
|
+
* overrides the directory remotely, and `/tmp` stays as the fallback when the
|
|
46
|
+
* home directory is not writable.
|
|
42
47
|
*/
|
|
43
48
|
export function buildPosixBackgroundCommand(builtCommand) {
|
|
44
49
|
const script = [
|
|
45
|
-
'
|
|
50
|
+
'LOG_DIR=${DSH_SSH_WORKBENCH_LOG_DIR:-$HOME/.dsh/ssh-workbench/logs}',
|
|
51
|
+
'mkdir -p "$LOG_DIR" 2>/dev/null || LOG_DIR=/tmp',
|
|
52
|
+
'LOG=$(mktemp "$LOG_DIR/dsh-ssh-workbench-bg.XXXXXX" 2>/dev/null || echo "$LOG_DIR/dsh-ssh-workbench-bg-$$-$(date +%s).log")',
|
|
46
53
|
`nohup "\${SHELL:-/bin/sh}" -lc ${quotePosix(builtCommand)} >"$LOG" 2>&1 &`,
|
|
47
54
|
`echo "${BG_PID_MARKER}$!"`,
|
|
48
55
|
`echo "${BG_LOG_MARKER}$LOG"`,
|
|
@@ -50,10 +57,10 @@ export function buildPosixBackgroundCommand(builtCommand) {
|
|
|
50
57
|
return `sh -c ${quotePosix(script)}`
|
|
51
58
|
}
|
|
52
59
|
|
|
53
|
-
/**
|
|
54
|
-
export function
|
|
60
|
+
/** The pid/log/stop lines shared by every background result renderer. */
|
|
61
|
+
export function renderBackgroundDetails({ pid, log, errLog, dialect = 'posix' }) {
|
|
55
62
|
const windows = dialect === 'windows'
|
|
56
|
-
const lines = [
|
|
63
|
+
const lines = []
|
|
57
64
|
if (pid !== undefined) lines.push(`pid: ${pid}`)
|
|
58
65
|
if (log) lines.push(`log: ${log}`)
|
|
59
66
|
if (errLog) lines.push(`stderr log: ${errLog}`)
|
|
@@ -65,5 +72,12 @@ export function renderBackgroundStart({ pid, log, errLog, target, command, diale
|
|
|
65
72
|
)
|
|
66
73
|
if (pid !== undefined) lines.push(windows ? `stop: Stop-Process -Id ${pid} -Force` : `stop: kill ${pid}`)
|
|
67
74
|
lines.push('The process is detached from this tool call: timeoutMs does not apply and it keeps running until it exits or is stopped.')
|
|
75
|
+
return lines
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** One model-facing description of a started background process. */
|
|
79
|
+
export function renderBackgroundStart({ pid, log, errLog, target, command, dialect = 'posix' }) {
|
|
80
|
+
const lines = [`background: started${target ? ` on ${target}` : ''}`, `command: ${command}`]
|
|
81
|
+
lines.push(...renderBackgroundDetails({ pid, log, errLog, dialect }))
|
|
68
82
|
return lines.join('\n')
|
|
69
83
|
}
|
package/lib/core/broker.js
CHANGED
|
@@ -177,13 +177,16 @@ export function createSessionBroker({ getSecret, knownHosts, config = {}, log =
|
|
|
177
177
|
return `${server.host}|${server.port}|${server.username}|${auth}`
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
-
function connectOptions(server, state) {
|
|
180
|
+
function connectOptions(server, state, connectTimeoutMs) {
|
|
181
181
|
const hostKeyMode = settings.hostKeyMode === 'accept-new' ? 'accept-new' : 'strict'
|
|
182
|
+
const readyTimeout = Number.isInteger(connectTimeoutMs) && connectTimeoutMs > 0
|
|
183
|
+
? Math.min(connectTimeoutMs, settings.connectTimeoutMs)
|
|
184
|
+
: settings.connectTimeoutMs
|
|
182
185
|
return {
|
|
183
186
|
host: server.host,
|
|
184
187
|
port: server.port,
|
|
185
188
|
username: server.username,
|
|
186
|
-
readyTimeout
|
|
189
|
+
readyTimeout,
|
|
187
190
|
keepaliveInterval: settings.keepaliveInterval,
|
|
188
191
|
keepaliveCountMax: settings.keepaliveCountMax,
|
|
189
192
|
hostHash: 'sha256',
|
|
@@ -197,7 +200,7 @@ export function createSessionBroker({ getSecret, knownHosts, config = {}, log =
|
|
|
197
200
|
}
|
|
198
201
|
|
|
199
202
|
/** Open a fresh client and resolve on `ready`. */
|
|
200
|
-
async function connectClient(server) {
|
|
203
|
+
async function connectClient(server, connectTimeoutMs) {
|
|
201
204
|
const { Client } = await loadSsh2()
|
|
202
205
|
const state = {}
|
|
203
206
|
const client = new Client()
|
|
@@ -226,7 +229,7 @@ export function createSessionBroker({ getSecret, knownHosts, config = {}, log =
|
|
|
226
229
|
log('ssh client error', { server: server.name, error: error instanceof Error ? error.message : String(error) })
|
|
227
230
|
})
|
|
228
231
|
try {
|
|
229
|
-
client.connect(connectOptions(server, state))
|
|
232
|
+
client.connect(connectOptions(server, state, connectTimeoutMs))
|
|
230
233
|
} catch (error) {
|
|
231
234
|
fail(error)
|
|
232
235
|
}
|
|
@@ -281,7 +284,7 @@ export function createSessionBroker({ getSecret, knownHosts, config = {}, log =
|
|
|
281
284
|
}
|
|
282
285
|
|
|
283
286
|
|
|
284
|
-
async function acquire(server) {
|
|
287
|
+
async function acquire(server, options = {}) {
|
|
285
288
|
const id = server.id ?? server.name
|
|
286
289
|
const key = serverKey(server)
|
|
287
290
|
let entry = entries.get(id)
|
|
@@ -328,7 +331,7 @@ export function createSessionBroker({ getSecret, knownHosts, config = {}, log =
|
|
|
328
331
|
}
|
|
329
332
|
created.pending = (async () => {
|
|
330
333
|
try {
|
|
331
|
-
const { client, fingerprint, connectMs } = await connectClient(server)
|
|
334
|
+
const { client, fingerprint, connectMs } = await connectClient(server, options.connectTimeoutMs)
|
|
332
335
|
created.client = client
|
|
333
336
|
created.fingerprint = fingerprint
|
|
334
337
|
created.connectMs = connectMs
|
|
@@ -589,7 +592,7 @@ export function createSessionBroker({ getSecret, knownHosts, config = {}, log =
|
|
|
589
592
|
for (;;) {
|
|
590
593
|
let acquired
|
|
591
594
|
try {
|
|
592
|
-
acquired = await acquire(server)
|
|
595
|
+
acquired = await acquire(server, { connectTimeoutMs: options.connectTimeoutMs })
|
|
593
596
|
} catch (error) {
|
|
594
597
|
const normalized = error instanceof SshError ? error : normalizeError(error)
|
|
595
598
|
const result = errorResult(normalized.code, normalized.message, { durationMs: now() - startedAt })
|
package/lib/core/dialect.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
/** The dialect values this plugin understands. */
|
|
12
|
-
|
|
12
|
+
const DIALECTS = Object.freeze({ POSIX: 'posix', WINDOWS: 'windows' })
|
|
13
13
|
|
|
14
14
|
/** Normalize one raw dialect/platform value. */
|
|
15
15
|
export function normalizeDialect(value) {
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session execution summary for the composer target dock.
|
|
3
|
+
*
|
|
4
|
+
* DSH owns conversation/model metrics (turns, TTFT, TPS); the plugin cannot
|
|
5
|
+
* read them. This module only summarizes the activity entries the plugin
|
|
6
|
+
* writes for remote executions, so the dock can show accurate SSH-side counts
|
|
7
|
+
* and latency without inventing values it does not own.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
function numericDuration(entry) {
|
|
11
|
+
return Number.isFinite(entry?.durationMs) && entry.durationMs >= 0 ? entry.durationMs : 0
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function emptyBucket(name) {
|
|
15
|
+
return { name, count: 0, success: 0, failed: 0, durationMs: 0, averageMs: 0, lastAt: null }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function addToBucket(bucket, entry) {
|
|
19
|
+
bucket.count += 1
|
|
20
|
+
if (entry.outcome === 'success') bucket.success += 1
|
|
21
|
+
else bucket.failed += 1
|
|
22
|
+
bucket.durationMs += numericDuration(entry)
|
|
23
|
+
if (typeof entry.at === 'string' && (bucket.lastAt === null || entry.at > bucket.lastAt)) bucket.lastAt = entry.at
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Summarize the plugin's remote execution activity for one Session.
|
|
28
|
+
*
|
|
29
|
+
* @param {object[]} entries newest-first activity entries
|
|
30
|
+
* @param {{ sessionId?: string, candidates?: string[] }} options
|
|
31
|
+
* @returns {{
|
|
32
|
+
* sessionId: string|null,
|
|
33
|
+
* count: number,
|
|
34
|
+
* success: number,
|
|
35
|
+
* failed: number,
|
|
36
|
+
* durationMs: number,
|
|
37
|
+
* averageMs: number,
|
|
38
|
+
* lastAt: string|null,
|
|
39
|
+
* servers: Array<{name:string,count:number,success:number,failed:number,durationMs:number,averageMs:number,lastAt:string|null}>
|
|
40
|
+
* }}
|
|
41
|
+
*/
|
|
42
|
+
export function summarizeExecution(entries, { sessionId, candidates = [] } = {}) {
|
|
43
|
+
const wanted = typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null
|
|
44
|
+
const byServer = new Map()
|
|
45
|
+
const summary = {
|
|
46
|
+
sessionId: wanted,
|
|
47
|
+
count: 0,
|
|
48
|
+
success: 0,
|
|
49
|
+
failed: 0,
|
|
50
|
+
durationMs: 0,
|
|
51
|
+
averageMs: 0,
|
|
52
|
+
lastAt: null,
|
|
53
|
+
servers: [],
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (wanted !== null) {
|
|
57
|
+
for (const entry of Array.isArray(entries) ? entries : []) {
|
|
58
|
+
if (!entry || entry.kind !== 'exec' || entry.sessionId !== wanted) continue
|
|
59
|
+
summary.count += 1
|
|
60
|
+
if (entry.outcome === 'success') summary.success += 1
|
|
61
|
+
else summary.failed += 1
|
|
62
|
+
summary.durationMs += numericDuration(entry)
|
|
63
|
+
if (typeof entry.at === 'string' && (summary.lastAt === null || entry.at > summary.lastAt)) summary.lastAt = entry.at
|
|
64
|
+
const name = typeof entry.server === 'string' && entry.server.length > 0 ? entry.server : null
|
|
65
|
+
if (name === null) continue
|
|
66
|
+
if (!byServer.has(name)) byServer.set(name, emptyBucket(name))
|
|
67
|
+
addToBucket(byServer.get(name), entry)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
summary.averageMs = summary.count > 0 ? Math.round(summary.durationMs / summary.count) : 0
|
|
72
|
+
|
|
73
|
+
// Keep the mentioned resources in mention order, then append any target that
|
|
74
|
+
// ran from an explicit remote_exec task without being a session candidate.
|
|
75
|
+
const names = []
|
|
76
|
+
for (const name of Array.isArray(candidates) ? candidates : []) {
|
|
77
|
+
const value = String(name)
|
|
78
|
+
if (value && !names.includes(value)) names.push(value)
|
|
79
|
+
}
|
|
80
|
+
for (const name of [...byServer.keys()].sort()) {
|
|
81
|
+
if (!names.includes(name)) names.push(name)
|
|
82
|
+
}
|
|
83
|
+
summary.servers = names.map((name) => {
|
|
84
|
+
const bucket = byServer.get(name) ?? emptyBucket(name)
|
|
85
|
+
return { ...bucket, averageMs: bucket.count > 0 ? Math.round(bucket.durationMs / bucket.count) : 0 }
|
|
86
|
+
})
|
|
87
|
+
return summary
|
|
88
|
+
}
|
package/lib/core/gate-pwsh.js
CHANGED
|
@@ -58,7 +58,7 @@ const RULES = [
|
|
|
58
58
|
]
|
|
59
59
|
|
|
60
60
|
/** Split one PowerShell command into independently-executed statements. */
|
|
61
|
-
|
|
61
|
+
function powerShellStatements(command) {
|
|
62
62
|
return String(command ?? '')
|
|
63
63
|
.split(/[;\r\n]|\|\||&&|\|/)
|
|
64
64
|
.map((part) => part.trim())
|
package/lib/core/guard.js
CHANGED
|
@@ -1,25 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Fallback blacklist for remote
|
|
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.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
16
|
-
|
|
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
|
-
|
|
21
|
+
const REMOTE_PWSH_TOOL_NAME = 'remote_pwsh'
|
|
20
22
|
|
|
21
|
-
/**
|
|
22
|
-
const
|
|
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({
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
+
}
|