ssh-mcp 2.3.0 → 2.3.1

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,27 @@ 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: reported.** There are no mode bits, so it reads the ACL and tells you what it
64
+ grants — a config under `%APPDATA%` inherits access for you, `SYSTEM` and `Administrators`
65
+ and needs nothing done to it, while one created elsewhere does not: a file under `C:\`
66
+ inherits read for every local account and modify for every authenticated one. The message
67
+ names the two `icacls` commands that fix it, and then the config loads anyway.
68
+
69
+ Advisory rather than enforced because enforced was tried first: 2.3.0 refused, and it
70
+ blocked a config at the documented `%APPDATA%` location whose ACL carried a principal the
71
+ allowlist had never seen ([#138](https://github.com/tufantunc/ssh-mcp/issues/138)). A check
72
+ whose worst outcome is locking you out of your own config is not worth that. Pass
73
+ `--strictConfigAcl` to refuse instead; under it, `--allowUncheckedConfigAcl` still loads a
74
+ config whose ACL could not be determined at all.
72
75
 
73
76
  ### Exit statuses
74
77
 
@@ -602,6 +605,7 @@ Secrets are **never** passed as CLI arguments.
602
605
  | `--allowedHosts` | bind address + localhost | Comma-separated Host headers accepted by the DNS-rebinding guard |
603
606
  | `--insecureHostKey` | false | Disable host key verification (test only!) |
604
607
  | `--allowUncheckedConfigAcl` | false | Windows: load the config even if its ACL could not be read |
608
+ | `--strictConfigAcl` | Windows: refuse to start on a broad or unreadable config ACL instead of reporting it |
605
609
  | `--disableApproval` | false | Skip the approval gate (quick start profile only) |
606
610
  | `--opaUrl` | — | OPA sidecar URL for external policy |
607
611
  | `--commandQuota` | 0 (off) | Max commands per rolling 24h per profile |
@@ -760,25 +760,30 @@ export async function assertPrivateOnWindows(filePath, opts = {}, inspect) {
760
760
  const verdict = await look(path);
761
761
  if (verdict.status === 'restricted')
762
762
  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, [])));
763
+ if (verdict.status === 'broad' || verdict.status === 'no-dacl') {
764
+ const grants = verdict.status === 'broad' ? verdict.grants : [];
765
+ const what = verdict.status === 'broad'
766
+ ? `is readable beyond its owner: access is granted to ${grants.map((g) => g.name).join(', ')}. ` +
767
+ 'Required: this account, SYSTEM and Administrators only.'
768
+ : 'has no access control list at all, which on Windows means full control for ' +
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;
773
777
  }
774
778
  // status === 'unknown'
775
- if (verdict.reason === 'tool-missing' || verdict.reason === 'timed-out' || opts.allowUnchecked) {
779
+ if (!opts.strict || verdict.reason === 'tool-missing' || verdict.reason === 'timed-out'
780
+ || opts.allowUnchecked) {
776
781
  report({ path, reason: verdict.reason, detail: verdict.detail });
777
782
  continue;
778
783
  }
779
784
  throw new OperatorError(`Config ${kind} ${path} could not be checked for access by other accounts ` +
780
785
  `(${verdict.reason}: ${verdict.detail}).\n` +
781
- 'This is refused rather than assumed safe, because the config decides which hosts, ' +
786
+ 'Refused because --strictConfigAcl was given, and the config decides which hosts, ' +
782
787
  'roles and policy rules this server honours.\n' +
783
788
  'Either fix the cause, move the config under %APPDATA%\\ssh-mcp, or pass ' +
784
789
  '--allowUncheckedConfigAcl to load it unverified.');
package/build/index.js CHANGED
@@ -130,6 +130,7 @@ async function buildAppConfig(argv) {
130
130
  // the audit store as well needs an AuditRecord variant for a startup event — that type
131
131
  // is hash-chained and tamper-evident, so it is its own change, and it is queued.
132
132
  const aclOpts = {
133
+ strict: flagEnabled(argv, 'strictConfigAcl'),
133
134
  allowUnchecked: flagEnabled(argv, 'allowUncheckedConfigAcl'),
134
135
  onUnverified: (e) => {
135
136
  unverified.push(e);
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.1",
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": {