ssh-mcp 2.3.1 → 2.3.3
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/README.md +29 -15
- package/build/config/loader.js +13 -11
- package/build/config/windows-acl.js +171 -47
- package/build/index.js +42 -15
- package/build/ssh/channel-signal.js +193 -0
- package/build/ssh/connection-registry.js +4 -3
- package/build/ssh/connection.js +64 -45
- package/build/ssh/session-manager.js +33 -12
- package/build/ssh/session.js +28 -4
- package/build/tools/descriptions.js +1 -1
- package/build/tools/session-tools.js +58 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -60,18 +60,32 @@ which is why `chmod 700` is in that command, since `mkdir -p` under the default
|
|
|
60
60
|
leaves the directory 0755. The server refuses to start otherwise. "Only the owner" is
|
|
61
61
|
unambiguous here and `chmod` is a one-line fix.
|
|
62
62
|
|
|
63
|
-
**Windows:
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
config
|
|
63
|
+
**Windows: split by what the ACL actually allows.** There are no mode bits, so the ACL is
|
|
64
|
+
read instead — and read exposure and write exposure are not treated alike, because Windows
|
|
65
|
+
is much clearer about one of them than the other.
|
|
66
|
+
|
|
67
|
+
| The ACL lets another account… | Default |
|
|
68
|
+
|---|---|
|
|
69
|
+
| only **read** the config | reported, and the server starts |
|
|
70
|
+
| **change** the config | refused |
|
|
71
|
+
| nothing (no ACL at all) | refused — that is full control for everyone |
|
|
72
|
+
| …and if the ACL could not be read | refused, except when `icacls` is absent or the check timed out |
|
|
73
|
+
|
|
74
|
+
A config under `%APPDATA%` inherits access for you, `SYSTEM` and `Administrators` and needs
|
|
75
|
+
nothing done to it. One created elsewhere does not: a file under `C:\` inherits *read* for
|
|
76
|
+
every local account and *modify* for every authenticated one. The message names the two
|
|
77
|
+
`icacls` commands that fix it either way.
|
|
78
|
+
|
|
79
|
+
Read exposure is reported rather than refused because that is where Windows is genuinely
|
|
80
|
+
muddier than POSIX, and refusing over it blocked a config at the documented location
|
|
81
|
+
([#138](https://github.com/tufantunc/ssh-mcp/issues/138)). Write exposure is refused
|
|
82
|
+
because it is not muddy at all: another account being able to rewrite the file that decides
|
|
83
|
+
which hosts, roles and approval policy this server honours is an authorization bypass, not
|
|
84
|
+
a disclosure.
|
|
85
|
+
|
|
86
|
+
Two flags move the whole thing: `--strictConfigAcl` refuses everything the check objects
|
|
87
|
+
to, read-only grants included; `--allowUncheckedConfigAcl` reports everything and refuses
|
|
88
|
+
nothing. Neither combination leaves you without an exit, which is the lesson of #138.
|
|
75
89
|
|
|
76
90
|
### Exit statuses
|
|
77
91
|
|
|
@@ -124,7 +138,7 @@ claude mcp add --transport stdio ssh-mcp -- ssh-mcp
|
|
|
124
138
|
| `list-connections` | Discover available hosts and connection status | ✅ | — |
|
|
125
139
|
| `list-sessions` | List active sessions per host | ✅ | — |
|
|
126
140
|
| `open-session` | Create a named interactive (stateful) or background session | — | — |
|
|
127
|
-
| `close-session` | Close a session
|
|
141
|
+
| `close-session` | Close a session. A background session's command is signalled (INT/TERM/KILL) before its channel is dropped | — | ✅ |
|
|
128
142
|
| `read-session-output` | Read output from background sessions (e.g., `tail -f`) | ✅ | — |
|
|
129
143
|
| `read-command` | Execute allowlisted read-only commands (`ls`, `cat`, `grep`, ...) | ✅ | — |
|
|
130
144
|
| `run-command` | Execute arbitrary commands (destructive ones need approval) | — | — |
|
|
@@ -604,8 +618,8 @@ Secrets are **never** passed as CLI arguments.
|
|
|
604
618
|
| `--rateLimit` | 0 | HTTP requests per minute on the MCP route (0 = unlimited) |
|
|
605
619
|
| `--allowedHosts` | bind address + localhost | Comma-separated Host headers accepted by the DNS-rebinding guard |
|
|
606
620
|
| `--insecureHostKey` | false | Disable host key verification (test only!) |
|
|
607
|
-
| `--allowUncheckedConfigAcl` | false | Windows:
|
|
608
|
-
| `--strictConfigAcl` | Windows: refuse
|
|
621
|
+
| `--allowUncheckedConfigAcl` | false | Windows: report every ACL finding and refuse none |
|
|
622
|
+
| `--strictConfigAcl` | false | Windows: refuse on every ACL finding, including a read-only over-grant |
|
|
609
623
|
| `--disableApproval` | false | Skip the approval gate (quick start profile only) |
|
|
610
624
|
| `--opaUrl` | — | OPA sidecar URL for external policy |
|
|
611
625
|
| `--commandQuota` | 0 (off) | Max commands per rolling 24h per profile |
|
package/build/config/loader.js
CHANGED
|
@@ -20,19 +20,21 @@ export function getConfigPath(customPath) {
|
|
|
20
20
|
return join(xdgConfig, 'ssh-mcp', 'config.toml');
|
|
21
21
|
}
|
|
22
22
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
23
|
+
* Check that nobody but the owner can reach a config file, because it holds the hosts,
|
|
24
|
+
* roles and policy rules that decide what this server will run.
|
|
25
25
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* rejected. The `chmod 600` it prescribed could not lift that rejection —
|
|
31
|
-
* measured on Windows 11, `chmod(path, 0o600)` leaves the mode at `0o666`,
|
|
32
|
-
* because `fs.chmod` there only toggles the read-only bit. Following the
|
|
33
|
-
* instruction exactly returned the operator to where they started.
|
|
26
|
+
* The two platforms answer differently on purpose. POSIX refuses: "only the owner" is
|
|
27
|
+
* unambiguous there and `chmod` is a one-line fix. Windows refuses a config another
|
|
28
|
+
* account can *change* and reports one it can only *read*, because read exposure is where
|
|
29
|
+
* the platform is genuinely muddier — see `waived` in windows-acl.ts.
|
|
34
30
|
*
|
|
35
|
-
* Windows
|
|
31
|
+
* Mode bits are POSIX's answer and are meaningless on Windows, which is the whole of #138:
|
|
32
|
+
* Node synthesises `0o666` for every readable file there, so `& 0o077` was non-zero
|
|
33
|
+
* unconditionally and every Windows config that has ever existed was rejected. The
|
|
34
|
+
* `chmod 600` it prescribed could not lift that rejection — measured on Windows 11,
|
|
35
|
+
* `chmod(path, 0o600)` leaves the mode at `0o666`, because `fs.chmod` there only toggles
|
|
36
|
+
* the read-only bit. Following the instruction exactly returned the operator to where
|
|
37
|
+
* they started.
|
|
36
38
|
*/
|
|
37
39
|
export async function checkPermissions(filePath, opts = {}) {
|
|
38
40
|
return platform() === 'win32'
|
|
@@ -68,7 +68,7 @@ const CHILD_ENV = { SystemRoot: join(SYSTEM32, '..'), Path: SYSTEM32 };
|
|
|
68
68
|
*
|
|
69
69
|
* Generous, because running out is not evidence about the file: five seconds across
|
|
70
70
|
* three process creations plus two temp-directory round trips, so that on-access
|
|
71
|
-
* virus scanning or a loaded host does not turn a private config into a
|
|
71
|
+
* virus scanning or a loaded host does not turn a private config into a finding.
|
|
72
72
|
* Expiry gets its own verdict for the same reason — see `timed-out`.
|
|
73
73
|
*/
|
|
74
74
|
const ACL_TIMEOUT_MS = 5_000;
|
|
@@ -202,6 +202,56 @@ const DACL_CONTROL_FLAGS = /^(?:P|AI|AR)*$/;
|
|
|
202
202
|
const ACE_FLAG_CODES = new Set([
|
|
203
203
|
'CI', 'OI', 'IO', 'NP', 'ID', 'SA', 'FA', 'TP', 'CR',
|
|
204
204
|
]);
|
|
205
|
+
/**
|
|
206
|
+
* Rights that let a trustee change the object, as SDDL letter codes. `WD` here is
|
|
207
|
+
* WRITE_DAC, not Everyone — the rights field and the trustee field have separate
|
|
208
|
+
* vocabularies that happen to share spellings.
|
|
209
|
+
*/
|
|
210
|
+
const WRITE_RIGHT_CODES = new Set([
|
|
211
|
+
'FA', 'FW', 'KA', 'KW', 'GA', 'GW', 'SD', 'WD', 'WO', 'CC', 'DC', 'SW', 'WP', 'DT',
|
|
212
|
+
]);
|
|
213
|
+
/** Rights that only let a trustee look. Anything unrecognised counts as write. */
|
|
214
|
+
const READ_RIGHT_CODES = new Set([
|
|
215
|
+
'FR', 'FX', 'GR', 'GX', 'KR', 'RC', 'RP', 'LC', 'LO',
|
|
216
|
+
]);
|
|
217
|
+
/**
|
|
218
|
+
* FILE_WRITE_DATA, FILE_APPEND_DATA, FILE_WRITE_EA, FILE_WRITE_ATTRIBUTES, DELETE,
|
|
219
|
+
* WRITE_DAC, WRITE_OWNER, GENERIC_WRITE, GENERIC_ALL.
|
|
220
|
+
*/
|
|
221
|
+
const WRITE_MASK = 0x2 | 0x4 | 0x10 | 0x100 | 0x10000 | 0x40000 | 0x80000 | 0x40000000 | 0x10000000;
|
|
222
|
+
/**
|
|
223
|
+
* Whether a rights field lets its trustee *change* the object rather than only read it.
|
|
224
|
+
*
|
|
225
|
+
* This distinction is the basis of the default posture, and the field used to be parsed
|
|
226
|
+
* and discarded. Read exposure on Windows is genuinely muddier than under POSIX — a
|
|
227
|
+
* shared volume grants `BUILTIN\Users` read, and refusing over that stranded the reporter
|
|
228
|
+
* of #138 — but write exposure is not muddy at all: "an ACE grants a non-owner
|
|
229
|
+
* FILE_WRITE_DATA or WRITE_DAC" is exactly as unambiguous as `0o022`, and a config another
|
|
230
|
+
* account can rewrite decides which hosts, roles and approval policy this server honours.
|
|
231
|
+
* That is an authorization bypass, not a disclosure, and it was being reported as one.
|
|
232
|
+
*
|
|
233
|
+
* Measured from real inheritance: `0x1200a9` is read-and-execute, `0x1301bf` is modify.
|
|
234
|
+
* Anything this cannot classify counts as write, because refusing is the safe direction.
|
|
235
|
+
*/
|
|
236
|
+
function grantsWrite(rights) {
|
|
237
|
+
const field = rights.trim().toUpperCase();
|
|
238
|
+
if (field === '')
|
|
239
|
+
return true;
|
|
240
|
+
if (/^0X[0-9A-F]+$/.test(field))
|
|
241
|
+
return (Number.parseInt(field.slice(2), 16) & WRITE_MASK) !== 0;
|
|
242
|
+
if (/^[0-9]+$/.test(field))
|
|
243
|
+
return (Number.parseInt(field, 10) & WRITE_MASK) !== 0;
|
|
244
|
+
if (field.length % 2 !== 0)
|
|
245
|
+
return true;
|
|
246
|
+
for (let i = 0; i < field.length; i += 2) {
|
|
247
|
+
const code = field.slice(i, i + 2);
|
|
248
|
+
if (WRITE_RIGHT_CODES.has(code))
|
|
249
|
+
return true;
|
|
250
|
+
if (!READ_RIGHT_CODES.has(code))
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
205
255
|
/**
|
|
206
256
|
* The SID of the account this process runs as.
|
|
207
257
|
*
|
|
@@ -265,7 +315,8 @@ async function currentUserSid(signal) {
|
|
|
265
315
|
* executed by nothing.
|
|
266
316
|
*
|
|
267
317
|
* Two failures say nothing about the file — icacls being absent from the machine, and
|
|
268
|
-
* the check running out of time — and those are the two the caller loads anyway
|
|
318
|
+
* the check running out of time — and those are the two the caller loads anyway even
|
|
319
|
+
* under `--strictConfigAcl`.
|
|
269
320
|
* Everything else (a non-zero exit, output we could not read) is about this path.
|
|
270
321
|
*
|
|
271
322
|
* Which makes "absent" worth being sure of: absent *here* is not enough, because a
|
|
@@ -520,7 +571,9 @@ export function parseDacl(descriptor, identity) {
|
|
|
520
571
|
const sid = resolveTrustee(trustee, accountDomain);
|
|
521
572
|
if (sid !== null && allowedSids.has(sid))
|
|
522
573
|
continue;
|
|
523
|
-
grants.set(sid ?? trustee, {
|
|
574
|
+
grants.set(sid ?? trustee, {
|
|
575
|
+
name: nameFor(trustee), trustee, sid, writes: grantsWrite(fields[2]),
|
|
576
|
+
});
|
|
524
577
|
}
|
|
525
578
|
return grants.size === 0 ? { status: 'restricted' } : { status: 'broad', grants: [...grants.values()] };
|
|
526
579
|
}
|
|
@@ -567,7 +620,7 @@ export function aclIdentity(sid) {
|
|
|
567
620
|
// RID 500 is the built-in Administrator. In a default configuration it belongs
|
|
568
621
|
// to the Administrators group, which is already allowed, so naming the account
|
|
569
622
|
// directly grants nobody new access — while refusing it is a false positive,
|
|
570
|
-
// and here that means refusing
|
|
623
|
+
// and here that means refusing, or at best warning, about a config that is fine. (On a
|
|
571
624
|
// domain-joined host the account domain is the domain, so this authorises
|
|
572
625
|
// DOMAIN\Administrator, whose local-admin status comes from Domain Admins
|
|
573
626
|
// nesting — a GPO default rather than a law.)
|
|
@@ -699,7 +752,26 @@ function remediation(path, kind, grants = []) {
|
|
|
699
752
|
// reaches icacls literally, which reports "Failed processing 1 files" and
|
|
700
753
|
// changes nothing. Measured. An instruction that only works in one shell is
|
|
701
754
|
// the same defect as the `chmod 600` this whole change replaced.
|
|
702
|
-
|
|
755
|
+
//
|
|
756
|
+
// Guarded because this is now on the *reporting* path, not only the throwing one. A
|
|
757
|
+
// Windows service under a virtual account, or a container with no loaded profile, makes
|
|
758
|
+
// `userInfo()` throw ENOENT — which escaped as a raw stack and cost the operator their
|
|
759
|
+
// config, which is #138 one environment over. A message that cannot name the account is
|
|
760
|
+
// still worth printing.
|
|
761
|
+
//
|
|
762
|
+
// A visible placeholder rather than `process.env.USERNAME`, which was the first spelling
|
|
763
|
+
// of this fallback. In the environments where `userInfo()` actually throws the env var is
|
|
764
|
+
// either unset or holds something icacls will not accept — a virtual service account, or
|
|
765
|
+
// `MACHINE$` — so it would print a command that fails, which is the defect class this
|
|
766
|
+
// whole change exists to remove. A placeholder the operator has to fill in cannot be
|
|
767
|
+
// followed by mistake.
|
|
768
|
+
let account;
|
|
769
|
+
try {
|
|
770
|
+
account = userInfo().username;
|
|
771
|
+
}
|
|
772
|
+
catch {
|
|
773
|
+
account = '<your account>';
|
|
774
|
+
}
|
|
703
775
|
return ('Restrict it with these two commands, in this order:\n' +
|
|
704
776
|
` icacls "${path}" /inheritance:d\n` +
|
|
705
777
|
` icacls "${path}" /grant:r "${account}:${rights}"${removals}\n` +
|
|
@@ -729,15 +801,91 @@ function relocation(path, kind, grants) {
|
|
|
729
801
|
remediation(path, kind, grants));
|
|
730
802
|
}
|
|
731
803
|
/**
|
|
732
|
-
*
|
|
733
|
-
*
|
|
734
|
-
*
|
|
735
|
-
*
|
|
736
|
-
*
|
|
737
|
-
*
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
804
|
+
* What a verdict means for one subject, as text and as data — and nothing else.
|
|
805
|
+
*
|
|
806
|
+
* Pure, so every message is assertable without a subprocess or a `console.error` spy, and
|
|
807
|
+
* so the refusal and the report cannot drift apart: they sat fifteen lines apart and had
|
|
808
|
+
* already diverged, one calling the same condition "was not checked" and the other "could
|
|
809
|
+
* not be checked".
|
|
810
|
+
*/
|
|
811
|
+
function describe(path, kind, verdict) {
|
|
812
|
+
if (verdict.status === 'restricted')
|
|
813
|
+
return null;
|
|
814
|
+
if (verdict.status === 'unknown') {
|
|
815
|
+
return {
|
|
816
|
+
path,
|
|
817
|
+
kind,
|
|
818
|
+
status: 'unchecked',
|
|
819
|
+
reason: verdict.reason,
|
|
820
|
+
detail: verdict.detail,
|
|
821
|
+
message: `Config ${kind} ${path} could not be checked for access by other accounts ` +
|
|
822
|
+
`(${verdict.reason}: ${verdict.detail}).\n` +
|
|
823
|
+
'Either fix the cause, move the config under %APPDATA%\\ssh-mcp, or pass ' +
|
|
824
|
+
'--allowUncheckedConfigAcl to accept it unverified.',
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
// An empty grants list for a NULL DACL is deliberate: there is nothing to `/remove:g`,
|
|
828
|
+
// and the fix is to give the object an ACL at all. `remediation` reads that emptiness.
|
|
829
|
+
const grants = verdict.status === 'broad' ? verdict.grants : [];
|
|
830
|
+
const writers = grants.filter((g) => g.writes);
|
|
831
|
+
const readers = grants.filter((g) => !g.writes);
|
|
832
|
+
const what = verdict.status === 'no-dacl'
|
|
833
|
+
? 'has no access control list at all, which on Windows means full control for every ' +
|
|
834
|
+
'account on the machine.'
|
|
835
|
+
: writers.length > 0
|
|
836
|
+
// Named as modifiable, not "readable". Calling a modify grant readable understated an
|
|
837
|
+
// integrity failure as a disclosure — and the two now decide different postures, so
|
|
838
|
+
// the wording has to tell them apart.
|
|
839
|
+
? `can be modified by accounts other than its owner: ${writers.map((g) => g.name).join(', ')}` +
|
|
840
|
+
`${readers.length ? `, and read by ${readers.map((g) => g.name).join(', ')}` : ''}. ` +
|
|
841
|
+
'Required: this account, SYSTEM and Administrators only.'
|
|
842
|
+
: `is readable beyond its owner: access is granted to ${grants.map((g) => g.name).join(', ')}. ` +
|
|
843
|
+
'Required: this account, SYSTEM and Administrators only.';
|
|
844
|
+
return {
|
|
845
|
+
path,
|
|
846
|
+
kind,
|
|
847
|
+
status: verdict.status,
|
|
848
|
+
grants,
|
|
849
|
+
message: `Config ${kind} ${path} ${what}\n` +
|
|
850
|
+
(isTightenable(path, kind) ? remediation(path, kind, grants) : relocation(path, kind, grants)),
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* Whether a finding is reported rather than refused.
|
|
855
|
+
*
|
|
856
|
+
* Three postures, two flags, and deliberately no combination that leaves an operator with
|
|
857
|
+
* no exit — the lesson of #138, and why this is a function rather than one boolean.
|
|
858
|
+
*
|
|
859
|
+
* By default: a grant that only lets another account *read* is reported, because that is
|
|
860
|
+
* where Windows is muddier than POSIX and refusing over it stranded a real operator. A
|
|
861
|
+
* grant that lets another account *change* the config is refused, because that is an
|
|
862
|
+
* authorization bypass and Windows is not muddy about it. An ACL that could not be
|
|
863
|
+
* determined is refused too — #138 was a known-bad verdict, never an undeterminable one,
|
|
864
|
+
* and flipping those open would let an attacker swap a loud finding for a vague one for
|
|
865
|
+
* free. The two exceptions stay: `icacls` absent, and the check running out of time, both
|
|
866
|
+
* statements about the machine rather than about the file.
|
|
867
|
+
*
|
|
868
|
+
* `--allowUncheckedConfigAcl` reports everything and refuses nothing — the single exit.
|
|
869
|
+
* `--strictConfigAcl` refuses everything the check objects to, read-only grants included.
|
|
870
|
+
*/
|
|
871
|
+
function waived(finding, opts) {
|
|
872
|
+
if (opts.allowUnchecked)
|
|
873
|
+
return true;
|
|
874
|
+
if (opts.enforce)
|
|
875
|
+
return false;
|
|
876
|
+
if (finding.status === 'unchecked') {
|
|
877
|
+
return finding.reason === 'tool-missing' || finding.reason === 'timed-out';
|
|
878
|
+
}
|
|
879
|
+
// A NULL DACL is full control for everyone, so it is never read-only.
|
|
880
|
+
return finding.status === 'broad' && !finding.grants.some((g) => g.writes);
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* Report what a config's ACL grants beyond its owner, and refuse when it is not merely
|
|
884
|
+
* readable — the Windows half of `checkPermissions`.
|
|
885
|
+
*
|
|
886
|
+
* Both the file and its directory are examined. What happens to a finding is
|
|
887
|
+
* `waived`'s decision; see it for the three postures and why the default is not to refuse
|
|
888
|
+
* a read-only over-grant.
|
|
741
889
|
*/
|
|
742
890
|
export async function assertPrivateOnWindows(filePath, opts = {}, inspect) {
|
|
743
891
|
// One deadline for the whole check, shared by every subprocess it starts.
|
|
@@ -748,44 +896,20 @@ export async function assertPrivateOnWindows(filePath, opts = {}, inspect) {
|
|
|
748
896
|
const signal = AbortSignal.timeout(ACL_TIMEOUT_MS);
|
|
749
897
|
return raceDeadline(inspectAcl(p, signal), signal);
|
|
750
898
|
});
|
|
751
|
-
const report = opts.
|
|
752
|
-
console.error(`Warning: the ACL of ${e.path} was not checked (${e.reason}: ${e.detail}), so whether ` +
|
|
753
|
-
'other accounts can read your config is unverified. Loading it anyway.');
|
|
754
|
-
});
|
|
899
|
+
const report = opts.onFinding ?? ((f) => console.error(`Warning: ${f.message}`));
|
|
755
900
|
const subjects = [
|
|
756
901
|
{ path: filePath, kind: 'file' },
|
|
757
902
|
{ path: parsePath(filePath).dir || '.', kind: 'directory' },
|
|
758
903
|
];
|
|
759
904
|
for (const { path, kind } of subjects) {
|
|
760
|
-
const
|
|
761
|
-
if (
|
|
905
|
+
const finding = describe(path, kind, await look(path));
|
|
906
|
+
if (finding === null)
|
|
762
907
|
continue;
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
'every account on the machine.';
|
|
770
|
-
const message = `Config ${kind} ${path} ${what}\n` +
|
|
771
|
-
(isTightenable(path, kind) ? remediation(path, kind, grants) : relocation(path, kind, grants));
|
|
772
|
-
if (opts.strict)
|
|
773
|
-
throw new OperatorError(message);
|
|
774
|
-
// Advisory by default; see AclOptions.strict for why.
|
|
775
|
-
console.error(`Warning: ${message}`);
|
|
776
|
-
continue;
|
|
777
|
-
}
|
|
778
|
-
// status === 'unknown'
|
|
779
|
-
if (!opts.strict || verdict.reason === 'tool-missing' || verdict.reason === 'timed-out'
|
|
780
|
-
|| opts.allowUnchecked) {
|
|
781
|
-
report({ path, reason: verdict.reason, detail: verdict.detail });
|
|
782
|
-
continue;
|
|
783
|
-
}
|
|
784
|
-
throw new OperatorError(`Config ${kind} ${path} could not be checked for access by other accounts ` +
|
|
785
|
-
`(${verdict.reason}: ${verdict.detail}).\n` +
|
|
786
|
-
'Refused because --strictConfigAcl was given, and the config decides which hosts, ' +
|
|
787
|
-
'roles and policy rules this server honours.\n' +
|
|
788
|
-
'Either fix the cause, move the config under %APPDATA%\\ssh-mcp, or pass ' +
|
|
789
|
-
'--allowUncheckedConfigAcl to load it unverified.');
|
|
908
|
+
// One decision, read once. Refusing and reporting used to be decided in three places —
|
|
909
|
+
// twice on the same flag for the same question — with two `continue`s and two throw
|
|
910
|
+
// sites, and the advisory path wrote to stderr directly rather than through the sink.
|
|
911
|
+
if (!waived(finding, opts))
|
|
912
|
+
throw new OperatorError(finding.message);
|
|
913
|
+
report(finding);
|
|
790
914
|
}
|
|
791
915
|
}
|
package/build/index.js
CHANGED
|
@@ -115,12 +115,13 @@ export function resolveHostGroup(argv) {
|
|
|
115
115
|
throw new OperatorError(`Invalid --group=${group}. Expected one of: ${HOST_GROUPS.join(', ')}.`);
|
|
116
116
|
}
|
|
117
117
|
/**
|
|
118
|
-
* ACL
|
|
118
|
+
* Every ACL finding, in the order it happened — both "readable beyond its owner" and
|
|
119
|
+
* "could not be checked".
|
|
119
120
|
*
|
|
120
121
|
* Collected rather than only printed so `main()` can act on them once there is somewhere
|
|
121
122
|
* durable to put them; see the note at the sink below.
|
|
122
123
|
*/
|
|
123
|
-
const
|
|
124
|
+
const aclFindings = [];
|
|
124
125
|
async function buildAppConfig(argv) {
|
|
125
126
|
// The way past an ACL that could not be read, rather than an operator with no
|
|
126
127
|
// exit. See assertPrivateOnWindows.
|
|
@@ -130,12 +131,11 @@ async function buildAppConfig(argv) {
|
|
|
130
131
|
// the audit store as well needs an AuditRecord variant for a startup event — that type
|
|
131
132
|
// is hash-chained and tamper-evident, so it is its own change, and it is queued.
|
|
132
133
|
const aclOpts = {
|
|
133
|
-
|
|
134
|
+
enforce: flagEnabled(argv, 'strictConfigAcl'),
|
|
134
135
|
allowUnchecked: flagEnabled(argv, 'allowUncheckedConfigAcl'),
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
console.error(`Warning:
|
|
138
|
-
'whether other accounts can read your config is unverified. Loading it anyway.');
|
|
136
|
+
onFinding: (f) => {
|
|
137
|
+
aclFindings.push(f);
|
|
138
|
+
console.error(`Warning: ${f.message}`);
|
|
139
139
|
},
|
|
140
140
|
};
|
|
141
141
|
if (argv.config !== undefined) {
|
|
@@ -268,14 +268,29 @@ async function main() {
|
|
|
268
268
|
console.error('SSH MCP Server v2 running on stdio');
|
|
269
269
|
}
|
|
270
270
|
const reaperInterval = setInterval(() => {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
271
|
+
// Session reaping is awaited before connection reaping, and that order is load-bearing:
|
|
272
|
+
// closing a background session now signals its command, and `reapIdleConnections` gates
|
|
273
|
+
// on `sessionCount`, which drops to zero as soon as a session is closed. Fired without
|
|
274
|
+
// awaiting, the connection was torn down microseconds after the first signal and the
|
|
275
|
+
// TERM and KILL rungs were discarded — so a command that ignores INT survived its own
|
|
276
|
+
// reaping.
|
|
277
|
+
void (async () => {
|
|
278
|
+
const conns = registry.listConnections()
|
|
279
|
+
.map((info) => registry.get(info.profile))
|
|
280
|
+
.filter((c) => Boolean(c));
|
|
281
|
+
await Promise.all(conns.map((conn) => conn.reapExpiredSessions().catch(() => { })));
|
|
282
|
+
registry.reapIdleConnections();
|
|
283
|
+
})();
|
|
278
284
|
}, 60_000);
|
|
285
|
+
/**
|
|
286
|
+
* How long teardown gets before the process exits regardless.
|
|
287
|
+
*
|
|
288
|
+
* One kill ladder (3s) plus slack. Sessions and connections close concurrently, so this
|
|
289
|
+
* is the whole budget rather than a per-session one. Docker's default stop grace is 10s
|
|
290
|
+
* and Kubernetes' is 30s, so this stays comfortably inside both — the point is that we
|
|
291
|
+
* choose the moment we give up rather than being SIGKILLed in the middle of it.
|
|
292
|
+
*/
|
|
293
|
+
const SHUTDOWN_BUDGET_MS = 5000;
|
|
279
294
|
let isShuttingDown = false;
|
|
280
295
|
const cleanup = async () => {
|
|
281
296
|
if (isShuttingDown)
|
|
@@ -283,14 +298,26 @@ async function main() {
|
|
|
283
298
|
isShuttingDown = true;
|
|
284
299
|
console.error('Shutting down SSH MCP Server...');
|
|
285
300
|
clearInterval(reaperInterval);
|
|
301
|
+
// Audit first. An earlier version of this comment claimed session teardown wrote audit
|
|
302
|
+
// records that the old order dropped — it does not: nothing under src/ssh/ touches the
|
|
303
|
+
// audit store, only the `close-session` tool does, and that record is written and
|
|
304
|
+
// awaited during the tool call. So the reorder rescued nothing and put the final flush
|
|
305
|
+
// behind a teardown that now waits for kill ladders. Flushing first cannot lose a record
|
|
306
|
+
// that teardown does not write, and it survives a SIGKILL at the end of the grace period.
|
|
286
307
|
try {
|
|
287
308
|
await audit.close();
|
|
288
309
|
}
|
|
289
310
|
catch (e) {
|
|
290
311
|
console.error('audit.close failed:', e);
|
|
291
312
|
}
|
|
313
|
+
// Bounded, so `process.exit(0)` is reached deliberately rather than by the container
|
|
314
|
+
// runtime's SIGKILL. Teardown is concurrent now, so the honest ceiling is one kill
|
|
315
|
+
// ladder plus slack rather than one per session.
|
|
292
316
|
try {
|
|
293
|
-
await
|
|
317
|
+
await Promise.race([
|
|
318
|
+
registry.closeAll(),
|
|
319
|
+
new Promise((resolve) => setTimeout(resolve, SHUTDOWN_BUDGET_MS).unref()),
|
|
320
|
+
]);
|
|
294
321
|
}
|
|
295
322
|
catch (e) {
|
|
296
323
|
console.error('closeAll failed:', e);
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/** Outgoing states in which the far end still has a channel to receive a request. */
|
|
2
|
+
const OPEN_STATES = new Set(['open', 'eof']);
|
|
3
|
+
/**
|
|
4
|
+
* Whether ssh2's own `Channel.signal()` would put this request on the wire.
|
|
5
|
+
*
|
|
6
|
+
* A deliberate copy of lib/Channel.js:241-243 in ssh2 1.17, and the single place to
|
|
7
|
+
* update when that changes. Copying it is what lets us tell "ssh2 will handle this"
|
|
8
|
+
* from "ssh2 will silently discard it" — the distinction the whole module exists for.
|
|
9
|
+
*/
|
|
10
|
+
function ssh2WillSend(ch) {
|
|
11
|
+
return ch.type === 'session' && ch.writable === true && ch.outgoing?.state === 'open';
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Whether the transport can still carry a packet.
|
|
15
|
+
*
|
|
16
|
+
* ssh2 hands every packet to `onWrite`, which is `if (isWritable(sock)) sock.write(data)`
|
|
17
|
+
* (lib/client.js:303) — an unwritable socket means the packet is dropped with no error
|
|
18
|
+
* and no return value. Measured against 1.17.0: immediately after `client.end()`,
|
|
19
|
+
* `sock.writable` is false while `outgoing.state` is still `'eof'`, so the state guard
|
|
20
|
+
* below passes and the signal call returns without throwing. Reporting that as delivery
|
|
21
|
+
* is #146 one layer down, and it is reachable whenever a connection is closed under a
|
|
22
|
+
* running command — `SSHConnection.close()`, or the idle reaper, which does not consult
|
|
23
|
+
* `activeChannels`.
|
|
24
|
+
*
|
|
25
|
+
* All three of `isWritable`'s conjuncts, not just the first. The first version of this
|
|
26
|
+
* checked `sock.writable` alone, and ssh2 added the other two for a reason
|
|
27
|
+
* (nodejs/node#36029: `writable` stays true after the stream has ended). Measured:
|
|
28
|
+
*
|
|
29
|
+
* allowHalfOpen=false, peer FIN -> writable=false, isWritable()=false (agree)
|
|
30
|
+
* allowHalfOpen=true, peer FIN -> writable=true, isWritable()=false (disagree)
|
|
31
|
+
*
|
|
32
|
+
* and a ProxyJump connection's transport is exactly the second case — `connectConfig.sock`
|
|
33
|
+
* is a `forwardOut` channel, and ssh2's channels default to `allowHalfOpen: true`
|
|
34
|
+
* (lib/Channel.js:90). So the one-conjunct version was blind on the path that has its own
|
|
35
|
+
* integration test. Mirroring the whole predicate is also what `ssh2WillSend` does, and
|
|
36
|
+
* the asymmetry between the two was the actual defect.
|
|
37
|
+
*/
|
|
38
|
+
function onTheWire(ch) {
|
|
39
|
+
const sock = ch._client?._sock;
|
|
40
|
+
// Fail closed on a shape we cannot read: every other unknown in this module is reported
|
|
41
|
+
// as "not dispatched", and a guard whose job is to refuse when unsure must not be the
|
|
42
|
+
// one place that assumes the best.
|
|
43
|
+
if (sock === undefined || typeof sock.writable !== 'boolean')
|
|
44
|
+
return false;
|
|
45
|
+
return sock.writable && sock._readableState?.ended === false;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Send `name` to the process behind `channel`.
|
|
49
|
+
*
|
|
50
|
+
* Returns whether the request was **dispatched to the wire** — not whether the command
|
|
51
|
+
* stopped. Nothing in SSH acknowledges a signal request, the delivery semantics are the
|
|
52
|
+
* server's choice rather than the protocol's, and a target may refuse it outright, so
|
|
53
|
+
* "the process is gone" is not a fact this function can produce. What it can produce
|
|
54
|
+
* honestly is "I could not even ask", which is what the caller warns about, because a
|
|
55
|
+
* signal that goes nowhere while reporting success is the defect this module exists to
|
|
56
|
+
* remove.
|
|
57
|
+
*/
|
|
58
|
+
export function signalChannel(channel, name) {
|
|
59
|
+
const ch = channel;
|
|
60
|
+
const state = ch.outgoing?.state;
|
|
61
|
+
// Only session channels carry a process. ssh2 gates on this too, and a
|
|
62
|
+
// forwarded-tcpip channel has nothing to signal.
|
|
63
|
+
if (ch.type !== undefined && ch.type !== 'session')
|
|
64
|
+
return false;
|
|
65
|
+
// Already closing or closed: the request would have no channel to arrive on, and
|
|
66
|
+
// ssh2 may have handed the id to a new channel.
|
|
67
|
+
if (state !== undefined && !OPEN_STATES.has(state))
|
|
68
|
+
return false;
|
|
69
|
+
// Nothing can be dispatched through a socket ssh2 will not write to, and it will not
|
|
70
|
+
// tell us — so this has to be asked before either path claims success.
|
|
71
|
+
if (!onTheWire(ch))
|
|
72
|
+
return false;
|
|
73
|
+
// The supported path, taken whenever ssh2 will actually write the request.
|
|
74
|
+
if (ssh2WillSend(ch)) {
|
|
75
|
+
try {
|
|
76
|
+
channel.signal(name);
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const id = ch.outgoing?.id;
|
|
84
|
+
const protocol = ch._client?._protocol;
|
|
85
|
+
if (typeof id === 'number' && typeof protocol?.signal === 'function') {
|
|
86
|
+
try {
|
|
87
|
+
protocol.signal(id, name);
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// A shape we do not recognise. Ask ssh2 and report failure anyway: we cannot tell
|
|
95
|
+
// whether it sent anything, and claiming delivery we cannot verify is what made
|
|
96
|
+
// this bug invisible for so long. An unnecessary "may still be running" warning is
|
|
97
|
+
// the better way to be wrong.
|
|
98
|
+
try {
|
|
99
|
+
channel.signal(name);
|
|
100
|
+
}
|
|
101
|
+
catch { /* nothing left to try */ }
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Appended when a stop request could not even be dispatched, so nothing was asked of the
|
|
106
|
+
* host at all.
|
|
107
|
+
*
|
|
108
|
+
* Deliberately narrower than "the command is still running", which is not knowable from
|
|
109
|
+
* here: nothing acknowledges a signal request. Its *absence* therefore means "we asked",
|
|
110
|
+
* not "it died".
|
|
111
|
+
*
|
|
112
|
+
* It lives beside the function that decides it because two callers now need the same
|
|
113
|
+
* sentence — a timed-out or cancelled `exec`, and `close-session` on a background
|
|
114
|
+
* session. One contract, one place.
|
|
115
|
+
*/
|
|
116
|
+
export const COULD_NOT_SIGNAL = ' The remote command could not be signalled, so it may still be running on the host.';
|
|
117
|
+
/** How long each signal is given to work before the next one is tried. */
|
|
118
|
+
const ESCALATION_MS = 1000;
|
|
119
|
+
/**
|
|
120
|
+
* Escalate until the command is gone: INT, then TERM, then KILL, then drop the
|
|
121
|
+
* channel.
|
|
122
|
+
*
|
|
123
|
+
* KILL is last because it denies the command any chance to clean up; it exists
|
|
124
|
+
* because the rung below it used to be `close()`, and closing a channel was measured
|
|
125
|
+
* not to stop anything. A command that ignores INT and TERM used to run forever.
|
|
126
|
+
*
|
|
127
|
+
* Returns whether the *first* signal was dispatched — known synchronously, so the
|
|
128
|
+
* caller can say "I could not even ask" in the same breath as the timeout it is already
|
|
129
|
+
* reporting. It is not a claim that the command stopped, and callers must not read it
|
|
130
|
+
* as one: the later rungs have not run yet, and no rung is acknowledged by anything.
|
|
131
|
+
*/
|
|
132
|
+
export function terminateChannel(channel) {
|
|
133
|
+
const dispatched = signalChannel(channel, 'INT');
|
|
134
|
+
const pending = [];
|
|
135
|
+
const later = (fn, ms) => {
|
|
136
|
+
// Unreferenced: this ladder outlives the promise it settled by up to three
|
|
137
|
+
// seconds, and a referenced timer would add that to the exit of any process
|
|
138
|
+
// whose last act was a timed-out command.
|
|
139
|
+
pending.push(setTimeout(fn, ms).unref());
|
|
140
|
+
};
|
|
141
|
+
const stop = () => { for (const t of pending)
|
|
142
|
+
clearTimeout(t); pending.length = 0; };
|
|
143
|
+
// Nothing more to send once the channel is gone, and the id may be reused.
|
|
144
|
+
channel.once('close', stop);
|
|
145
|
+
later(() => { signalChannel(channel, 'TERM'); }, ESCALATION_MS);
|
|
146
|
+
later(() => { signalChannel(channel, 'KILL'); }, ESCALATION_MS * 2);
|
|
147
|
+
later(() => { try {
|
|
148
|
+
channel.close();
|
|
149
|
+
}
|
|
150
|
+
catch { /* already gone */ } }, ESCALATION_MS * 3);
|
|
151
|
+
return dispatched;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Wait for a channel to actually close, bounded.
|
|
155
|
+
*
|
|
156
|
+
* The ladder's rungs are timers, so `terminateChannel` returns long before the escalation
|
|
157
|
+
* has finished. Anything that tears the transport down afterwards — `SSHConnection.close()`
|
|
158
|
+
* calling `client.end()`, or the process exiting — cancels the rest of it, and a command
|
|
159
|
+
* that ignored INT then survives with no further escalation possible. Callers that are
|
|
160
|
+
* about to do that must wait here first.
|
|
161
|
+
*
|
|
162
|
+
* Resolves true if the channel closed, false on timeout. The default budget is the ladder
|
|
163
|
+
* plus a little, so a command that only dies to KILL still gets there.
|
|
164
|
+
*/
|
|
165
|
+
export function waitForChannelClose(channel, ms = ESCALATION_MS * 3 + 500) {
|
|
166
|
+
// ssh2 emits `'close'` exactly once, and `onCHANNEL_CLOSE` early-returns for a channel
|
|
167
|
+
// whose incoming state is already `'closed'` — so a listener attached after the fact
|
|
168
|
+
// never fires and the caller would burn the whole budget waiting for an event that has
|
|
169
|
+
// already happened. Not reachable through SessionManager today, because the session is
|
|
170
|
+
// removed from the map by that same emit; it is a trap for the next caller.
|
|
171
|
+
const incoming = channel.incoming;
|
|
172
|
+
if (incoming?.state === 'closed' || channel.destroyed)
|
|
173
|
+
return Promise.resolve(true);
|
|
174
|
+
return new Promise((resolve) => {
|
|
175
|
+
let settled = false;
|
|
176
|
+
const onClose = () => done(true);
|
|
177
|
+
const done = (closed) => {
|
|
178
|
+
if (settled)
|
|
179
|
+
return;
|
|
180
|
+
settled = true;
|
|
181
|
+
clearTimeout(timer);
|
|
182
|
+
// Detached on the timeout path too: a timed-out wait would otherwise leave its
|
|
183
|
+
// closure attached to a channel that may outlive it.
|
|
184
|
+
channel.removeListener('close', onClose);
|
|
185
|
+
resolve(closed);
|
|
186
|
+
};
|
|
187
|
+
const timer = setTimeout(() => done(false), ms);
|
|
188
|
+
// Unreferenced so a hung channel cannot hold the process open; the caller is awaiting
|
|
189
|
+
// this, so the loop stays alive for as long as the caller itself does.
|
|
190
|
+
timer.unref();
|
|
191
|
+
channel.once('close', onClose);
|
|
192
|
+
});
|
|
193
|
+
}
|
|
@@ -94,9 +94,10 @@ export class ConnectionRegistry {
|
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
async closeAll() {
|
|
97
|
-
for
|
|
98
|
-
|
|
99
|
-
|
|
97
|
+
// Concurrently, for the reason SessionManager.closeAll gives: each connection now waits
|
|
98
|
+
// for its sessions' kill ladders, and serialising multiplied that wait by the number of
|
|
99
|
+
// profiles.
|
|
100
|
+
await Promise.all([...this.connections.values()].map((conn) => conn.close().catch(() => { })));
|
|
100
101
|
this.connections.clear();
|
|
101
102
|
}
|
|
102
103
|
}
|
package/build/ssh/connection.js
CHANGED
|
@@ -6,6 +6,40 @@ import { tracer } from '../observability/tracer.js';
|
|
|
6
6
|
import { redactText } from '../guard/redactor.js';
|
|
7
7
|
import { shellSingleQuote } from '../guard/sanitizer.js';
|
|
8
8
|
import { openWithRetry } from './channel-retry.js';
|
|
9
|
+
import { terminateChannel, COULD_NOT_SIGNAL } from './channel-signal.js';
|
|
10
|
+
/**
|
|
11
|
+
* Stop the command behind `channel`, record why on the span, and return the sentence the
|
|
12
|
+
* rejection should carry.
|
|
13
|
+
*
|
|
14
|
+
* Module scope rather than a closure inside `exec`: it captures nothing but the span, and
|
|
15
|
+
* at module scope it is directly testable with a stub span and a fake channel. It exists
|
|
16
|
+
* at all because the three settle paths used to inline this, and two of them computed the
|
|
17
|
+
* "unstopped" value for the message and then dropped it before the span — so
|
|
18
|
+
* `ssh.unstopped` was structurally incapable of being true for a cancelled command, which
|
|
19
|
+
* is the most common way an MCP client stops one.
|
|
20
|
+
*/
|
|
21
|
+
export function stopAndDescribe(span, reason, channel) {
|
|
22
|
+
span.setAttribute(reason, true);
|
|
23
|
+
// A null channel means the exec callback has not arrived yet. It does NOT mean nothing is
|
|
24
|
+
// running — ssh2 invokes that callback on CHANNEL_SUCCESS, which OpenSSH sends after
|
|
25
|
+
// forking the command — but there is nothing to warn about either: `onExecChannel` stops
|
|
26
|
+
// such a late channel the moment it arrives. Warning here would put "may still be
|
|
27
|
+
// running" on every timeout that races a slow channel open, and a warning that fires when
|
|
28
|
+
// nothing is wrong stops being read.
|
|
29
|
+
//
|
|
30
|
+
// Recorded as its own attribute rather than as `ssh.unstopped: false`, which would assert
|
|
31
|
+
// a clean stop this path cannot yet know about. The late-arrival handler emits the real
|
|
32
|
+
// answer on its own span.
|
|
33
|
+
if (channel === null) {
|
|
34
|
+
span.setAttribute('ssh.stopDeferred', true);
|
|
35
|
+
return '';
|
|
36
|
+
}
|
|
37
|
+
const dispatched = terminateChannel(channel);
|
|
38
|
+
// Set on every settle path, always, so "stopped cleanly" and "ran an older build" are not
|
|
39
|
+
// the same absent-attribute query.
|
|
40
|
+
span.setAttribute('ssh.unstopped', !dispatched);
|
|
41
|
+
return dispatched ? '' : COULD_NOT_SIGNAL;
|
|
42
|
+
}
|
|
9
43
|
export class SSHConnection {
|
|
10
44
|
profile;
|
|
11
45
|
client = null;
|
|
@@ -193,25 +227,9 @@ export class SSHConnection {
|
|
|
193
227
|
const timeoutId = setTimeout(() => {
|
|
194
228
|
if (!resolved) {
|
|
195
229
|
resolved = true;
|
|
196
|
-
|
|
197
|
-
try {
|
|
198
|
-
activeStream.signal('INT');
|
|
199
|
-
}
|
|
200
|
-
catch { /* */ }
|
|
201
|
-
setTimeout(() => {
|
|
202
|
-
try {
|
|
203
|
-
activeStream?.signal('TERM');
|
|
204
|
-
}
|
|
205
|
-
catch { /* */ }
|
|
206
|
-
setTimeout(() => { try {
|
|
207
|
-
activeStream?.close();
|
|
208
|
-
}
|
|
209
|
-
catch { /* */ } }, 1000);
|
|
210
|
-
}, 1000);
|
|
211
|
-
}
|
|
212
|
-
span.setAttribute('ssh.timedOut', true);
|
|
230
|
+
const note = stopAndDescribe(span, 'ssh.timedOut', activeStream);
|
|
213
231
|
span.end();
|
|
214
|
-
reject(new Error(`Command timed out after ${timeoutMs}ms`));
|
|
232
|
+
reject(new Error(`Command timed out after ${timeoutMs}ms${note}`));
|
|
215
233
|
}
|
|
216
234
|
}, timeoutMs);
|
|
217
235
|
const execOpts = {};
|
|
@@ -240,6 +258,23 @@ export class SSHConnection {
|
|
|
240
258
|
}
|
|
241
259
|
return;
|
|
242
260
|
}
|
|
261
|
+
// The promise may already have settled — the timeout can fire while
|
|
262
|
+
// `openWithRetry` is still in flight, and ssh2 only invokes this callback once
|
|
263
|
+
// the server has replied CHANNEL_SUCCESS, which OpenSSH sends *after* forking
|
|
264
|
+
// the command. Without this the command ran to completion on the host after the
|
|
265
|
+
// caller had been told it timed out, holding a channel and discarding its
|
|
266
|
+
// output: #146 exactly, in the one path the first fix did not cover.
|
|
267
|
+
if (resolved) {
|
|
268
|
+
// On its own span: the exec span was ended when the promise settled, and an
|
|
269
|
+
// attribute set on an ended span is silently dropped. Without this the one
|
|
270
|
+
// outcome `stopAndDescribe` deferred would be unobservable — the shape this
|
|
271
|
+
// whole change exists to remove.
|
|
272
|
+
const late = tracer.startSpan('ssh.exec.lateStop');
|
|
273
|
+
late.setAttribute('ssh.host', this.profile.host);
|
|
274
|
+
late.setAttribute('ssh.unstopped', !terminateChannel(stream));
|
|
275
|
+
late.end();
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
243
278
|
activeStream = stream;
|
|
244
279
|
this.activeChannels++;
|
|
245
280
|
let stdout = '';
|
|
@@ -261,42 +296,26 @@ export class SSHConnection {
|
|
|
261
296
|
if (opts.abortSignal.aborted) {
|
|
262
297
|
resolved = true;
|
|
263
298
|
clearTimeout(timeoutId);
|
|
299
|
+
// Decremented here rather than on 'close', because the close listener
|
|
300
|
+
// below is never attached on this path. The channel now outlives the
|
|
301
|
+
// count by as long as the kill ladder takes.
|
|
264
302
|
this.activeChannels--;
|
|
265
303
|
// client.exec() has already dispatched the command, so rejecting
|
|
266
304
|
// without signalling left it running to completion on the host and
|
|
267
|
-
// held a channel until it finished.
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
}
|
|
271
|
-
catch { /* */ }
|
|
272
|
-
try {
|
|
273
|
-
stream.close();
|
|
274
|
-
}
|
|
275
|
-
catch { /* */ }
|
|
305
|
+
// held a channel until it finished. Closing the channel does not stop
|
|
306
|
+
// it either — only a delivered signal does (#146).
|
|
307
|
+
const note = stopAndDescribe(span, 'ssh.aborted', stream);
|
|
276
308
|
span.end();
|
|
277
|
-
reject(new Error(
|
|
309
|
+
reject(new Error(`Command aborted before execution${note}`));
|
|
278
310
|
return;
|
|
279
311
|
}
|
|
280
312
|
const onAbort = () => {
|
|
281
313
|
if (!resolved) {
|
|
282
314
|
resolved = true;
|
|
283
315
|
clearTimeout(timeoutId);
|
|
284
|
-
|
|
285
|
-
stream.signal('INT');
|
|
286
|
-
}
|
|
287
|
-
catch { /* */ }
|
|
288
|
-
setTimeout(() => {
|
|
289
|
-
try {
|
|
290
|
-
stream.signal('TERM');
|
|
291
|
-
}
|
|
292
|
-
catch { /* */ }
|
|
293
|
-
setTimeout(() => { try {
|
|
294
|
-
stream.close();
|
|
295
|
-
}
|
|
296
|
-
catch { /* */ } }, 1000);
|
|
297
|
-
}, 1000);
|
|
316
|
+
const note = stopAndDescribe(span, 'ssh.aborted', stream);
|
|
298
317
|
span.end();
|
|
299
|
-
reject(new Error(
|
|
318
|
+
reject(new Error(`Command aborted${note}`));
|
|
300
319
|
}
|
|
301
320
|
};
|
|
302
321
|
opts.abortSignal.addEventListener('abort', onAbort, { once: true });
|
|
@@ -359,8 +378,8 @@ export class SSHConnection {
|
|
|
359
378
|
async closeSession(name) {
|
|
360
379
|
return this.sessions.close(name);
|
|
361
380
|
}
|
|
362
|
-
reapExpiredSessions() {
|
|
363
|
-
this.sessions.reapExpired();
|
|
381
|
+
async reapExpiredSessions() {
|
|
382
|
+
return this.sessions.reapExpired();
|
|
364
383
|
}
|
|
365
384
|
async close() {
|
|
366
385
|
await this.sessions.closeAll();
|
|
@@ -140,22 +140,43 @@ export class SessionManager {
|
|
|
140
140
|
const session = this.sessions.get(name);
|
|
141
141
|
if (!session)
|
|
142
142
|
throw new Error(`Session "${name}" not found on ${this.deps.profile().name}`);
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
146
|
-
async closeAll() {
|
|
147
|
-
for (const name of [...this.sessions.keys()]) {
|
|
148
|
-
await this.close(name).catch(() => { });
|
|
143
|
+
try {
|
|
144
|
+
return await session.close();
|
|
149
145
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
146
|
+
finally {
|
|
147
|
+
// Guarded and in a `finally`, both because closing now awaits a kill ladder rather
|
|
148
|
+
// than returning on the next microtask. Unguarded, a reaper closing this session
|
|
149
|
+
// while the caller re-opened the same name would evict the *new* session — the
|
|
150
|
+
// hazard `register` already documents. Without the `finally`, a rejection left the
|
|
151
|
+
// entry behind forever, holding a `sessionMaxPerConnection` slot and blocking the
|
|
152
|
+
// name.
|
|
153
|
+
if (this.sessions.get(name) === session)
|
|
155
154
|
this.sessions.delete(name);
|
|
156
|
-
}
|
|
157
155
|
}
|
|
158
156
|
}
|
|
157
|
+
async closeAll() {
|
|
158
|
+
// Concurrently. The ladders are independent timers on independent channels, and
|
|
159
|
+
// sequentially this was measured at 10.0s for five commands that ignore INT and TERM —
|
|
160
|
+
// past Docker's 10s default stop grace, so the container was SIGKILLed mid-teardown and
|
|
161
|
+
// the later sessions got no escalation at all. Before the ladder was awaited each
|
|
162
|
+
// session at least received its INT immediately, so serialising made the tail worse
|
|
163
|
+
// than the bug it fixed.
|
|
164
|
+
await Promise.all([...this.sessions.keys()].map((name) => this.close(name).catch(() => { })));
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Close every expired session, awaiting the escalation.
|
|
168
|
+
*
|
|
169
|
+
* Awaited, and async for that reason. Fire-and-forget left exactly one hole in the
|
|
170
|
+
* guarantee `waitForChannelClose` exists for: `close()` dispatched INT and returned, the
|
|
171
|
+
* entry was deleted so `sessionCount` hit zero in the same tick, and the reaper's very
|
|
172
|
+
* next call — `reapIdleConnections`, which gates on `sessionCount` — closed the
|
|
173
|
+
* connection and ended the client microseconds later. TERM and KILL were then discarded,
|
|
174
|
+
* so a background command that ignores INT survived its own reaping.
|
|
175
|
+
*/
|
|
176
|
+
async reapExpired() {
|
|
177
|
+
const expired = [...this.sessions.entries()].filter(([, s]) => s.isExpired());
|
|
178
|
+
await Promise.all(expired.map(([name]) => this.close(name).catch(() => { })));
|
|
179
|
+
}
|
|
159
180
|
markAllDisconnected() {
|
|
160
181
|
for (const session of this.sessions.values()) {
|
|
161
182
|
session.markDisconnected();
|
package/build/ssh/session.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomBytes } from 'crypto';
|
|
2
2
|
import { tracer } from '../observability/tracer.js';
|
|
3
|
+
import { terminateChannel, waitForChannelClose } from './channel-signal.js';
|
|
3
4
|
const ANSI_REGEX = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07]*\x07|\r/g;
|
|
4
5
|
export function stripAnsi(text) {
|
|
5
6
|
return text.replace(ANSI_REGEX, '');
|
|
@@ -250,6 +251,9 @@ export class InteractiveSession extends Session {
|
|
|
250
251
|
}
|
|
251
252
|
catch { /* ignore */ }
|
|
252
253
|
this._status = 'closed';
|
|
254
|
+
// Nothing remote is signalled here: an interactive session's stop is `^C` written into
|
|
255
|
+
// its live pty during a command, and closing the session just ends the shell.
|
|
256
|
+
return 'closed';
|
|
253
257
|
}
|
|
254
258
|
get currentCwd() {
|
|
255
259
|
return this.cwd;
|
|
@@ -341,10 +345,30 @@ export class BackgroundSession extends Session {
|
|
|
341
345
|
throw new Error('Background sessions do not support run(). The command was started when the session was opened.');
|
|
342
346
|
}
|
|
343
347
|
async close() {
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
+
// Signalled, not just closed. A background session runs on an exec channel, and
|
|
349
|
+
// closing such a channel was measured not to stop the command (#146) — so
|
|
350
|
+
// `close-session` reported `status: 'closed'` while `tail -f` kept running on the
|
|
351
|
+
// host, which is the same false claim the exec path stopped making.
|
|
352
|
+
const dispatched = terminateChannel(this.stream);
|
|
353
|
+
// Our side is closed either way — the session is being torn down and the caller is not
|
|
354
|
+
// getting it back. What varies is whether the *host* stopped, which the outcome carries.
|
|
348
355
|
this._status = 'closed';
|
|
356
|
+
if (!dispatched)
|
|
357
|
+
return 'unsignalled';
|
|
358
|
+
// Awaited, because the ladder's later rungs are timers and whatever happens next may
|
|
359
|
+
// tear the transport down: `SSHConnection.close()` ends the client immediately after
|
|
360
|
+
// `closeAll()` returns, and shutdown calls `process.exit`. Returning early meant a
|
|
361
|
+
// command that ignored INT received nothing further and survived — the KILL rung
|
|
362
|
+
// exists precisely for that command. Bounded by the ladder's own length, so a command
|
|
363
|
+
// that dies to INT costs one round trip rather than three seconds.
|
|
364
|
+
//
|
|
365
|
+
// Skipped entirely when nothing was dispatched: no rung can be delivered through a
|
|
366
|
+
// transport that refused the first one, including the `channel.close()` rung, so
|
|
367
|
+
// `'close'` would never arrive and the wait was measured burning its full 3.5s for
|
|
368
|
+
// nothing.
|
|
369
|
+
//
|
|
370
|
+
// The old `stream.close()` released the channel synchronously; this releases it when
|
|
371
|
+
// the ladder finishes, which is why the wait is here rather than left to the caller.
|
|
372
|
+
return (await waitForChannelClose(this.stream)) ? 'closed' : 'stop-unconfirmed';
|
|
349
373
|
}
|
|
350
374
|
}
|
|
@@ -3,7 +3,7 @@ export const TOOL_DESCRIPTIONS = {
|
|
|
3
3
|
'list-connections': 'List all configured SSH profiles and their connection status. Use this to discover available hosts before running commands.',
|
|
4
4
|
'list-sessions': 'List active sessions for a given SSH profile.',
|
|
5
5
|
'open-session': 'Open a named session on a remote host. Use type="interactive" for stateful shell (CWD/env persists between commands) or type="background" for long-running processes.',
|
|
6
|
-
'close-session': 'Close a named session,
|
|
6
|
+
'close-session': 'Close a named session. A background session\'s command is signalled on the host (INT, then TERM, then KILL) before its channel is dropped; an interactive session\'s shell is ended. The response says so if the command could not be signalled or had not stopped in time.',
|
|
7
7
|
'read-session-output': 'Read recent output from a background session (e.g., tail -f logs).',
|
|
8
8
|
'read-command': 'Execute a READ-ONLY command from an allowlist (ls, cat, grep, find, stat, df, etc.). This tool does NOT modify the system. Prefer this tool for all read operations.',
|
|
9
9
|
'run-command': 'Execute an arbitrary shell command on the remote server. May modify the system. Destructive commands require user approval.',
|
|
@@ -2,11 +2,40 @@ import { z } from 'zod';
|
|
|
2
2
|
import { sanitizeSessionName } from '../guard/sanitizer.js';
|
|
3
3
|
import { redactText } from '../guard/redactor.js';
|
|
4
4
|
import { BackgroundSession } from '../ssh/session.js';
|
|
5
|
+
import { COULD_NOT_SIGNAL } from '../ssh/channel-signal.js';
|
|
5
6
|
import { TOOL_DESCRIPTIONS as D } from './descriptions.js';
|
|
6
7
|
import { syntheticSuccess, textResult } from './results.js';
|
|
7
8
|
/** Connection and session discovery/lifecycle tools. */
|
|
9
|
+
/**
|
|
10
|
+
* The evaluation recorded for a session release.
|
|
11
|
+
*
|
|
12
|
+
* Releasing a session is audited but never refused, which is why this is a constant rather
|
|
13
|
+
* than an engine call. The first version of this routed the close through `runAudited`, and
|
|
14
|
+
* that made the stop *refusable*: `session:close <name>` classifies as `safe`, so a
|
|
15
|
+
* `readOnly` profile — which can open a background session, because a `tail -f` classifies
|
|
16
|
+
* `read-only` — was denied permission to close it, and had no other way to stop the
|
|
17
|
+
* command for an hour. `ask-all` prompted on every close, and an exhausted
|
|
18
|
+
* `commandQuotaPerDay` wedged the profile entirely.
|
|
19
|
+
*
|
|
20
|
+
* A control whose refusal mode is "the thing you asked me to stop keeps running" is worse
|
|
21
|
+
* than the unaudited stop it replaced. The record was the part worth having; the veto was
|
|
22
|
+
* not. `ruleId` says so, so the log distinguishes this from an engine `allow`.
|
|
23
|
+
*/
|
|
24
|
+
const RELEASE = {
|
|
25
|
+
decision: 'allow',
|
|
26
|
+
commandClass: 'safe',
|
|
27
|
+
binary: 'session:close',
|
|
28
|
+
ruleId: 'session-release',
|
|
29
|
+
reason: 'A session release is audited but not policy-gated; see SECURITY.md.',
|
|
30
|
+
};
|
|
31
|
+
/** What to append to the caller's confirmation for each outcome. */
|
|
32
|
+
const CLOSE_NOTES = {
|
|
33
|
+
closed: '',
|
|
34
|
+
unsignalled: COULD_NOT_SIGNAL,
|
|
35
|
+
'stop-unconfirmed': ' The command was signalled but the channel had not closed in time, so it may still be running on the host.',
|
|
36
|
+
};
|
|
8
37
|
export function registerSessionTools({ server, registry }, pipeline) {
|
|
9
|
-
const { runAudited, resolveConn } = pipeline;
|
|
38
|
+
const { runAudited, resolveConn, auditResult, auditFailure, makeCtx, defaultProfileName } = pipeline;
|
|
10
39
|
// ─── list-connections ──────────────────────────────────────────────────
|
|
11
40
|
server.tool('list-connections', D["list-connections"], {}, { readOnlyHint: true }, async () => {
|
|
12
41
|
const connections = registry.listConnections();
|
|
@@ -73,10 +102,35 @@ export function registerSessionTools({ server, registry }, pipeline) {
|
|
|
73
102
|
server.tool('close-session', D["close-session"], {
|
|
74
103
|
name: z.string().describe('Session name to close'),
|
|
75
104
|
profile: z.string().optional().describe('Profile name'),
|
|
76
|
-
}, { destructiveHint: true }, async ({ name, profile }) => {
|
|
105
|
+
}, { destructiveHint: true }, async ({ name, profile }, extra) => {
|
|
106
|
+
const cleanName = sanitizeSessionName(name);
|
|
107
|
+
const profileName = defaultProfileName(profile);
|
|
108
|
+
const ctx = makeCtx(extra, profileName, cleanName);
|
|
77
109
|
const conn = await resolveConn(profile);
|
|
78
|
-
|
|
79
|
-
|
|
110
|
+
// Recorded with the session's kind, so a remote SIGKILL is greppable in the log and
|
|
111
|
+
// distinguishable from ending a local shell — `open-session` encodes its type the
|
|
112
|
+
// same way.
|
|
113
|
+
// Through `toInfo()` rather than an `instanceof` check: the type is public API, and a
|
|
114
|
+
// tool handler reaching for a constructor identity is the layer leak this repo's own
|
|
115
|
+
// rules call out.
|
|
116
|
+
const kind = conn.getSession(cleanName)?.toInfo().type ?? 'unknown';
|
|
117
|
+
const command = `session:close ${kind} ${cleanName}`;
|
|
118
|
+
const startedAt = Date.now();
|
|
119
|
+
try {
|
|
120
|
+
const outcome = await conn.closeSession(cleanName);
|
|
121
|
+
await auditResult(ctx, profileName, command, RELEASE, {
|
|
122
|
+
stdout: '',
|
|
123
|
+
stderr: '',
|
|
124
|
+
exitCode: outcome === 'closed' ? 0 : 1,
|
|
125
|
+
durationMs: Date.now() - startedAt,
|
|
126
|
+
profile: profileName,
|
|
127
|
+
});
|
|
128
|
+
return textResult(`Session "${cleanName}" closed.${CLOSE_NOTES[outcome]}`);
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
await auditFailure(ctx, profileName, { command }, 'safe', err);
|
|
132
|
+
throw err;
|
|
133
|
+
}
|
|
80
134
|
});
|
|
81
135
|
// ─── read-session-output ───────────────────────────────────────────────
|
|
82
136
|
server.tool('read-session-output', D["read-session-output"], {
|
package/package.json
CHANGED