feat(#93): filesystem permission pre-flight for credential configs (0600/0700)
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 13s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 13s
CI / Lint (ruff) (pull_request) Successful in 8s
CI / Tests (py3.12 / windows-latest) (pull_request) Failing after 24s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 12s
CI / Catalog signature (pull_request) Successful in 8s

ssh-mcp refuses to start if its config is group/world-readable (mode & 0o077 →
throws, requiring dir 0700 / file 0600). A GUI user has no idea what chmod 600
means — they just get a dead server. This checks it and offers a one-click fix.

Core (pure, POSIX-only, injectable stat/chmod so tests never touch a real file):
- permission_status(path): the ssh-mcp rule — any group/other bit set (mode & 0o077)
  is not-ok; file must be 0600, its dir 0700. Returns None on Windows (modes don't
  apply) or when the file is absent (nothing to pre-flight). Plain-language problems
  naming the offending octal mode.
- fix_permissions(path): chmod file → 0600, dir → 0700. No-op on Windows; reports
  an OSError instead of raising.
- sidecar_permission_warnings() / sidecar_permission_fix_target(): tie the check to
  #91's sidecar path resolution so it knows WHICH file to inspect. Mirror the
  sidecar-warnings shape.

GUI: a warning label + "Fix permissions" button in the stdio editor (mirrors the
removed-flag surface), shown only when the sidecar exists and is too open. Hidden
on Windows and for non-sidecar servers. Smoke-tested headlessly.

pytest green (529 passed), ruff + format clean. Closes #93. Part of epic #94.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Cowork Supervisor
2026-08-12 03:10:29 -04:00
co-authored by Claude Opus 4.8
parent 2e5d0351b4
commit 0b2827e6b8
3 changed files with 301 additions and 0 deletions
+45
View File
@@ -908,6 +908,21 @@ class ServerEditor(QFrame):
self.sidecar_warn.setWordWrap(True)
self.sidecar_warn.hide()
v.addWidget(self.sidecar_warn)
# Shown when the sidecar config is group/other-accessible (#93): ssh-mcp
# refuses to start unless it's 0600 / its dir 0700. One-click chmod fix.
# POSIX only — hidden on Windows where modes don't apply.
self.perm_warn = QLabel("")
self.perm_warn.setStyleSheet(f"color: {WARN};")
self.perm_warn.setWordWrap(True)
self.perm_warn.hide()
self.perm_fix_btn = QPushButton("Fix permissions")
self.perm_fix_btn.setToolTip("chmod the config file to 0600 and its directory to 0700")
self.perm_fix_btn.clicked.connect(self._fix_permissions)
self.perm_fix_btn.hide()
perm_row = QHBoxLayout()
perm_row.addWidget(self.perm_warn, 1)
perm_row.addWidget(self.perm_fix_btn)
v.addLayout(perm_row)
v.addWidget(self._lbl("Environment variables"))
self.env = KeyValueTable(
"Variable", "Value", on_change=self._emit, before_change=self._before_change
@@ -1010,6 +1025,8 @@ class ServerEditor(QFrame):
self.removed_flag_warn.hide()
self.removed_flag_fix_btn.hide()
self.sidecar_warn.hide()
self.perm_warn.hide()
self.perm_fix_btn.hide()
self._loading = False
return
self.setEnabled(True)
@@ -1100,6 +1117,8 @@ class ServerEditor(QFrame):
self.removed_flag_warn.hide()
self.removed_flag_fix_btn.hide()
self.sidecar_warn.hide()
self.perm_warn.hide()
self.perm_fix_btn.hide()
return
_, notes = core.split_suspicious_args(self._current_arg_lines())
if notes:
@@ -1140,6 +1159,15 @@ class ServerEditor(QFrame):
self.sidecar_warn.show()
else:
self.sidecar_warn.hide()
# Sidecar filesystem permissions (#93). Real platform/fs; no-op on Windows.
perm_warnings = core.sidecar_permission_warnings(stdio)
if perm_warnings:
self.perm_warn.setText("⚠ " + "\n".join(perm_warnings))
self.perm_warn.show()
self.perm_fix_btn.show()
else:
self.perm_warn.hide()
self.perm_fix_btn.hide()
def _fix_args(self):
if self._before_change:
@@ -1160,6 +1188,23 @@ class ServerEditor(QFrame):
self.env.load(migrated.get("env", {}))
self.args.setPlainText("\n".join(migrated.get("args", [])))
def _fix_permissions(self):
"""chmod the sidecar config to 0600 / its dir to 0700 (#93)."""
stdio = {
"command": self.command.text().strip(),
"args": self._current_arg_lines(),
"env": self.env.dump(),
}
target = core.sidecar_permission_fix_target(stdio)
if target is None:
return
changed, note = core.fix_permissions(target)
if not changed:
# Surface the failure in-place rather than silently doing nothing.
self.perm_warn.setText("⚠ " + (note or "could not change permissions"))
return
self._check_args() # re-check; the warning clears when perms are now tight
# --- dependency ------------------------------------------------------ #
def refresh_dependency(self, auto_open=False):
if not self.isEnabled():