ssh-mcp 2.3.0 → 2.3.2

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 CHANGED
@@ -51,24 +51,41 @@ approvalPolicy = "auto" # dev is permissive
51
51
  chmod 700 ~/.config/ssh-mcp && chmod 600 ~/.config/ssh-mcp/config.toml
52
52
  ```
53
53
 
54
- The server refuses to start if anyone but you can read the config, since it
55
- decides which hosts, roles and policy rules this server honours.
56
-
57
- On Linux and macOS that is the mode check above — the directory too, which is why
58
- `chmod 700` is in that command: `mkdir -p` under the default umask leaves it 0755. On Windows there are no mode
59
- bits, so it reads the ACL instead and requires that only your account, `SYSTEM`
60
- and `Administrators` hold access. A config under `%APPDATA%` inherits exactly
61
- that and needs nothing done to it. One created elsewhere does not — a file under
62
- `C:\` inherits read access for every local account and modify for every
63
- authenticated one — and the refusal names the two `icacls` commands that fix it,
64
- or, when the directory is a filesystem root or sits outside your user profile, leads with
65
- moving the config instead — it still prints the commands, but removing entries from a
66
- directory other accounts share is a judgement only you can make.
67
-
68
- If the ACL cannot be read at all, the server refuses rather than assuming the file is
69
- private, and `--allowUncheckedConfigAcl` loads it unverified. Two cases do not need the
70
- flag — `icacls` being absent from the machine, and the check running out of time — because
71
- both are statements about the machine rather than about the file. Each warns and loads.
54
+ The config decides which hosts, roles and policy rules this server honours, so it checks
55
+ that nobody but you can read it — and treats the two platforms differently, because the
56
+ question has a much clearer answer on one of them.
57
+
58
+ **Linux and macOS: enforced.** The mode check above, on the file *and* the directory —
59
+ which is why `chmod 700` is in that command, since `mkdir -p` under the default umask
60
+ leaves the directory 0755. The server refuses to start otherwise. "Only the owner" is
61
+ unambiguous here and `chmod` is a one-line fix.
62
+
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.
72
89
 
73
90
  ### Exit statuses
74
91
 
@@ -601,7 +618,8 @@ Secrets are **never** passed as CLI arguments.
601
618
  | `--rateLimit` | 0 | HTTP requests per minute on the MCP route (0 = unlimited) |
602
619
  | `--allowedHosts` | bind address + localhost | Comma-separated Host headers accepted by the DNS-rebinding guard |
603
620
  | `--insecureHostKey` | false | Disable host key verification (test only!) |
604
- | `--allowUncheckedConfigAcl` | false | Windows: load the config even if its ACL could not be read |
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 |
605
623
  | `--disableApproval` | false | Skip the approval gate (quick start profile only) |
606
624
  | `--opaUrl` | — | OPA sidecar URL for external policy |
607
625
  | `--commandQuota` | 0 (off) | Max commands per rolling 24h per profile |
@@ -20,19 +20,21 @@ export function getConfigPath(customPath) {
20
20
  return join(xdgConfig, 'ssh-mcp', 'config.toml');
21
21
  }
22
22
  /**
23
- * Refuse a config file that anyone but its owner can read, because it holds the
24
- * hosts, roles and policy rules that decide what this server will run.
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
- * One question, asked of two different access-control systems. Mode bits are
27
- * POSIX's answer and are meaningless on Windows, which is the whole of #138:
28
- * Node synthesises `0o666` for every readable file there, so `& 0o077` was
29
- * non-zero unconditionally and every Windows config that has ever existed was
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 reads the ACL instead; see assertPrivateOnWindows.
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 refusal.
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, { name: nameFor(trustee), trustee, sid });
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 to start a server whose config is fine. (On a
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
- const account = userInfo().username;
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
- * Refuse a config the owner is not alone in being able to read — the Windows
733
- * half of `checkPermissions`.
734
- *
735
- * Both the file and its directory are examined. An unreadable ACL is refused rather
736
- * than waved through, with two exceptions: `icacls` absent from the machine, and the
737
- * check running out of time. Both are statements about the machine rather than about
738
- * the file, and refusing over a question that cannot be asked here is what #138 was.
739
- * `--allowUncheckedConfigAcl` is the way past the rest, so an operator is never stuck
740
- * with no exit.
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,39 +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.onUnverified ?? ((e) => {
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 verdict = await look(path);
761
- if (verdict.status === 'restricted')
905
+ const finding = describe(path, kind, await look(path));
906
+ if (finding === null)
762
907
  continue;
763
- if (verdict.status === 'broad') {
764
- const named = verdict.grants.map((g) => g.name).join(', ');
765
- throw new OperatorError(`Config ${kind} ${path} is readable beyond its owner: access is granted to ${named}. ` +
766
- 'Required: this account, SYSTEM and Administrators only.\n' +
767
- (isTightenable(path, kind) ? remediation(path, kind, verdict.grants) : relocation(path, kind, verdict.grants)));
768
- }
769
- if (verdict.status === 'no-dacl') {
770
- throw new OperatorError(`Config ${kind} ${path} has no access control list at all, which on Windows means ` +
771
- 'full control for every account on the machine.\n' +
772
- (isTightenable(path, kind) ? remediation(path, kind) : relocation(path, kind, [])));
773
- }
774
- // status === 'unknown'
775
- if (verdict.reason === 'tool-missing' || verdict.reason === 'timed-out' || opts.allowUnchecked) {
776
- report({ path, reason: verdict.reason, detail: verdict.detail });
777
- continue;
778
- }
779
- throw new OperatorError(`Config ${kind} ${path} could not be checked for access by other accounts ` +
780
- `(${verdict.reason}: ${verdict.detail}).\n` +
781
- 'This is refused rather than assumed safe, because the config decides which hosts, ' +
782
- 'roles and policy rules this server honours.\n' +
783
- 'Either fix the cause, move the config under %APPDATA%\\ssh-mcp, or pass ' +
784
- '--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);
785
914
  }
786
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 checks that could not be performed, in the order they happened.
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 unverified = [];
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,11 +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 = {
134
+ enforce: flagEnabled(argv, 'strictConfigAcl'),
133
135
  allowUnchecked: flagEnabled(argv, 'allowUncheckedConfigAcl'),
134
- onUnverified: (e) => {
135
- unverified.push(e);
136
- console.error(`Warning: the ACL of ${e.path} was not checked (${e.reason}: ${e.detail}), so ` +
137
- '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}`);
138
139
  },
139
140
  };
140
141
  if (argv.config !== undefined) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ssh-mcp",
3
3
  "license": "MIT",
4
- "version": "2.3.0",
4
+ "version": "2.3.2",
5
5
  "description": "MCP server exposing policy-gated, audited SSH access for Linux and Windows hosts via the Model Context Protocol.",
6
6
  "type": "module",
7
7
  "bin": {