defense-mcp-server 0.7.2 → 0.7.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.
@@ -1 +1 @@
1
- {"version":3,"file":"command-allowlist.d.ts","sourceRoot":"","sources":["../../src/core/command-allowlist.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAeH,MAAM,WAAW,cAAc;IAC7B,wCAAwC;IACxC,MAAM,EAAE,MAAM,CAAC;IACf,gDAAgD;IAChD,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,2EAA2E;IAC3E,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,+EAA+E;IAC/E,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,uEAAuE;IACvE,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,sDAAsD;AACtD,MAAM,WAAW,wBAAwB;IACvC,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,OAAO,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;CACjB;AA8WD;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,IAAI,IAAI,CAkD1C;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAsDtD;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CActD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG;IAClD,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;CACpB,CA6CA;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,IAAI,aAAa,CAAC,QAAQ,CAAC,cAAc,CAAC,CAAC,CAE7E;AAED;;GAEG;AACH,wBAAgB,aAAa,IAAI,OAAO,CAEvC;AAED;;GAEG;AACH,wBAAgB,gCAAgC,IAAI,OAAO,CAE1D;AAED;;;GAGG;AACH,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAEjE;AA8CD;;GAEG;AACH,wBAAgB,yBAAyB,IAAI,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC,CAE9E;AAeD;;;;;;;;;GASG;AACH,wBAAgB,qBAAqB,CACnC,UAAU,EAAE,MAAM,EAClB,eAAe,CAAC,EAAE,MAAM,GACvB,wBAAwB,CA6G1B;AAiHD;;;;;;;;;;GAUG;AACH,wBAAsB,iBAAiB,IAAI,OAAO,CAAC,wBAAwB,EAAE,CAAC,CA6C7E"}
1
+ {"version":3,"file":"command-allowlist.d.ts","sourceRoot":"","sources":["../../src/core/command-allowlist.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAeH,MAAM,WAAW,cAAc;IAC7B,wCAAwC;IACxC,MAAM,EAAE,MAAM,CAAC;IACf,gDAAgD;IAChD,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,2EAA2E;IAC3E,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,+EAA+E;IAC/E,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,uEAAuE;IACvE,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,sDAAsD;AACtD,MAAM,WAAW,wBAAwB;IACvC,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,OAAO,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;CACjB;AA+WD;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,IAAI,IAAI,CAkD1C;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAsDtD;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CActD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG;IAClD,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;CACpB,CA6CA;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,IAAI,aAAa,CAAC,QAAQ,CAAC,cAAc,CAAC,CAAC,CAE7E;AAED;;GAEG;AACH,wBAAgB,aAAa,IAAI,OAAO,CAEvC;AAED;;GAEG;AACH,wBAAgB,gCAAgC,IAAI,OAAO,CAE1D;AAED;;;GAGG;AACH,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAEjE;AA8CD;;GAEG;AACH,wBAAgB,yBAAyB,IAAI,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC,CAE9E;AAeD;;;;;;;;;GASG;AACH,wBAAgB,qBAAqB,CACnC,UAAU,EAAE,MAAM,EAClB,eAAe,CAAC,EAAE,MAAM,GACvB,wBAAwB,CA6G1B;AAiHD;;;;;;;;;;GAUG;AACH,wBAAsB,iBAAiB,IAAI,OAAO,CAAC,wBAAwB,EAAE,CAAC,CA6C7E"}
@@ -241,6 +241,7 @@ const ALLOWLIST_DEFINITIONS = [
241
241
  // ── GUI askpass helpers (sudo-management.ts) ───────────────────────────
242
242
  { binary: "zenity", candidates: ["/usr/bin/zenity"] },
243
243
  { binary: "kdialog", candidates: ["/usr/bin/kdialog"] },
244
+ { binary: "setsid", candidates: ["/usr/bin/setsid", "/usr/sbin/setsid"] },
244
245
  { binary: "ksshaskpass", candidates: ["/usr/bin/ksshaskpass"] },
245
246
  { binary: "lxqt-sudo", candidates: ["/usr/bin/lxqt-sudo"] },
246
247
  // ── DNS security ───────────────────────────────────────────────────────
@@ -396,7 +396,7 @@ function buildFirewallPersistenceConfig(distro) {
396
396
  return {
397
397
  packageName: "iptables-persistent",
398
398
  checkInstalledCmd: ["dpkg", "-l", "iptables-persistent"],
399
- installCmd: ["DEBIAN_FRONTEND=noninteractive", "apt-get", "install", "-y", "iptables-persistent"],
399
+ installCmd: ["apt-get", "install", "-y", "iptables-persistent"],
400
400
  serviceName: "netfilter-persistent",
401
401
  enableCmd: ["systemctl", "enable", "netfilter-persistent"],
402
402
  saveCmd: ["netfilter-persistent", "save"],
@@ -135,12 +135,12 @@ export class SudoGuard {
135
135
  if (nopasswdPattern.test(content)) {
136
136
  logger.security("sudo-guard", "nopasswd_detected", "SECURITY CRITICAL: NOPASSWD:ALL detected in sudoers configuration. " +
137
137
  "Authentication via sudo_elevate is NON-FUNCTIONAL — any password will be accepted. " +
138
- "Remove NOPASSWD from the sudoers file and set a real password for mcpuser. " +
138
+ "Remove NOPASSWD from the sudoers file and set a real password for the user. " +
139
139
  "See docs/SUDO-SESSION-DESIGN.md for remediation steps.", {
140
140
  severity: "CRITICAL",
141
141
  location: filePath,
142
- remediation: "Rebuild the Docker image with the updated Dockerfile that uses " +
143
- "etc/sudoers.d/mcpuser (scoped allowlist, no NOPASSWD).",
142
+ remediation: "Update the sudoers configuration to use a scoped allowlist " +
143
+ "with no NOPASSWD. See docs/SUDO-SESSION-DESIGN.md for details.",
144
144
  });
145
145
  return { nopasswdDetected: true, location: filePath };
146
146
  }
@@ -1 +1 @@
1
- {"version":3,"file":"tool-dependencies.d.ts","sourceRoot":"","sources":["../../src/core/tool-dependencies.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAmB,KAAK,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAIvE;;GAEG;AACH,MAAM,WAAW,cAAc;IAC7B,kDAAkD;IAClD,QAAQ,EAAE,MAAM,CAAC;IACjB,yDAAyD;IACzD,gBAAgB,EAAE,MAAM,EAAE,CAAC;IAC3B,gEAAgE;IAChE,gBAAgB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC5B,gFAAgF;IAChF,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAaD;;GAEG;AACH,wBAAgB,2BAA2B,CACzC,MAAM,EAAE,MAAM,GACb,eAAe,GAAG,SAAS,CAE7B;AAID;;;;;;;GAOG;AACH,eAAO,MAAM,iBAAiB,EAAE,cAAc,EAuL7C,CAAC;AAUF;;GAEG;AACH,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,MAAM,GACf,cAAc,GAAG,SAAS,CAE5B;AAED;;GAEG;AACH,wBAAgB,sBAAsB,IAAI,MAAM,EAAE,CAQjD;AAED;;GAEG;AACH,wBAAgB,cAAc,IAAI;IAAE,QAAQ,EAAE,MAAM,EAAE,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAA;CAAE,CAiB3E;AAED;;GAEG;AACH,wBAAgB,uBAAuB,IAAI,cAAc,EAAE,CAE1D"}
1
+ {"version":3,"file":"tool-dependencies.d.ts","sourceRoot":"","sources":["../../src/core/tool-dependencies.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAmB,KAAK,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAIvE;;GAEG;AACH,MAAM,WAAW,cAAc;IAC7B,kDAAkD;IAClD,QAAQ,EAAE,MAAM,CAAC;IACjB,yDAAyD;IACzD,gBAAgB,EAAE,MAAM,EAAE,CAAC;IAC3B,gEAAgE;IAChE,gBAAgB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC5B,gFAAgF;IAChF,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAaD;;GAEG;AACH,wBAAgB,2BAA2B,CACzC,MAAM,EAAE,MAAM,GACb,eAAe,GAAG,SAAS,CAE7B;AAID;;;;;;;GAOG;AACH,eAAO,MAAM,iBAAiB,EAAE,cAAc,EAwL7C,CAAC;AAUF;;GAEG;AACH,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,MAAM,GACf,cAAc,GAAG,SAAS,CAE5B;AAED;;GAEG;AACH,wBAAgB,sBAAsB,IAAI,MAAM,EAAE,CAQjD;AAED;;GAEG;AACH,wBAAgB,cAAc,IAAI;IAAE,QAAQ,EAAE,MAAM,EAAE,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAA;CAAE,CAiB3E;AAED;;GAEG;AACH,wBAAgB,uBAAuB,IAAI,cAAc,EAAE,CAE1D"}
@@ -57,7 +57,8 @@ export const TOOL_DEPENDENCIES = [
57
57
  // ── Access Control (1) ────────────────────────────────────────────────────
58
58
  {
59
59
  toolName: "access_control",
60
- requiredBinaries: ["ssh", "sshd", "pam-auth-update"],
60
+ requiredBinaries: ["pam-auth-update"],
61
+ optionalBinaries: ["ssh", "sshd"],
61
62
  critical: true,
62
63
  },
63
64
  // ── Compliance (1) ────────────────────────────────────────────────────────
package/build/index.js CHANGED
@@ -187,7 +187,7 @@ async function main() {
187
187
  if (nopasswdCheck.nopasswdDetected) {
188
188
  console.error(`[startup] ⚠️ SECURITY CRITICAL: NOPASSWD:ALL detected in sudoers ` +
189
189
  `(${nopasswdCheck.location}). sudo_elevate credential validation is NON-FUNCTIONAL. ` +
190
- `Rebuild the Docker image with the updated Dockerfile.`);
190
+ `Remove the NOPASSWD entry from sudoers and set a real password for the MCP user.`);
191
191
  }
192
192
  else {
193
193
  console.error('[startup] ✅ Sudoers check: NOPASSWD:ALL not detected — credential validation active');
@@ -9,5 +9,6 @@
9
9
  * namespace_check, seccomp_profile, rootless_setup)
10
10
  */
11
11
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
12
+ export declare const DESKTOP_BREAKING_PROFILES: Set<string>;
12
13
  export declare function registerContainerSecurityTools(server: McpServer): void;
13
14
  //# sourceMappingURL=container-security.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"container-security.d.ts","sourceRoot":"","sources":["../../src/tools/container-security.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAsBpE,wBAAgB,8BAA8B,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAosBtE"}
1
+ {"version":3,"file":"container-security.d.ts","sourceRoot":"","sources":["../../src/tools/container-security.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAwBpE,eAAO,MAAM,yBAAyB,aAyBpC,CAAC;AAIH,wBAAgB,8BAA8B,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAuvBtE"}
@@ -20,6 +20,36 @@ import { resolve } from "node:path";
20
20
  import { secureWriteFileSync } from "../core/secure-fs.js";
21
21
  // ── TOOL-011 remediation: safe directory for seccomp profiles ──────────────
22
22
  const SECCOMP_PROFILE_DIR = "/tmp/defense-mcp/seccomp";
23
+ // ── AppArmor profiles known to break desktop applications ─────────────────
24
+ // These profiles ship in apparmor-profiles-extra and use ABI 4.0 default-deny
25
+ // with only `userns` granted. In enforce mode they block shared library loading
26
+ // for flatpak, chromium, and other GUI apps via the dynamic linker.
27
+ export const DESKTOP_BREAKING_PROFILES = new Set([
28
+ "flatpak",
29
+ "chromium",
30
+ "unprivileged_userns",
31
+ "chrome",
32
+ "brave",
33
+ "Discord",
34
+ "element-desktop",
35
+ "firefox",
36
+ "signal-desktop",
37
+ "slack",
38
+ "vivaldi-bin",
39
+ "opera",
40
+ "msedge",
41
+ "obsidian",
42
+ "steam",
43
+ "code",
44
+ "epiphany",
45
+ "github-desktop",
46
+ "polypane",
47
+ "qutebrowser",
48
+ // Container runtimes — same ABI 4.0 default-deny issue breaks Docker
49
+ "runc",
50
+ "crun",
51
+ "rootlesskit",
52
+ ]);
23
53
  // ── Registration entry point ───────────────────────────────────────────────
24
54
  export function registerContainerSecurityTools(server) {
25
55
  // ── 1. container_docker (audit + bench + seccomp + daemon + image_scan) ─
@@ -449,11 +479,17 @@ export function registerContainerSecurityTools(server) {
449
479
  try {
450
480
  const sections = ["🛡️ AppArmor System Status", "=".repeat(40)];
451
481
  const enabledResult = await executeCommand({ command: "aa-enabled", args: [], toolName: "container_isolation", timeout: 5000 });
452
- const aaEnabled = enabledResult.exitCode === 0 && enabledResult.stdout.trim() === "Yes";
453
- sections.push(`\n AppArmor enabled: ${aaEnabled ? "✅ Yes" : "❌ No"}`);
482
+ const aaEnabledBin = enabledResult.exitCode === 0 && enabledResult.stdout.trim() === "Yes";
454
483
  const moduleResult = await executeCommand({ command: "cat", args: ["/sys/module/apparmor/parameters/enabled"], toolName: "container_isolation", timeout: 5000 });
484
+ const kernelModuleLoaded = moduleResult.exitCode === 0 && moduleResult.stdout.trim() === "Y";
485
+ // Also check if apparmor service is active and profiles directory is populated
486
+ const svcResult = await executeCommand({ command: "systemctl", args: ["is-active", "apparmor"], toolName: "container_isolation", timeout: 5000 });
487
+ const svcActive = svcResult.exitCode === 0 && svcResult.stdout.trim() === "active";
488
+ // AppArmor is considered enabled if aa-enabled says "Yes", OR if kernel module is loaded AND service is active
489
+ const aaEnabled = aaEnabledBin || (kernelModuleLoaded && svcActive);
490
+ sections.push(`\n AppArmor enabled: ${aaEnabled ? "✅ Yes" : "❌ No"}`);
455
491
  if (moduleResult.exitCode === 0)
456
- sections.push(` Kernel module: ${moduleResult.stdout.trim() === "Y" ? "✅ Loaded" : "❌ Not loaded"}`);
492
+ sections.push(` Kernel module: ${kernelModuleLoaded ? "✅ Loaded" : "❌ Not loaded"}`);
457
493
  const pkgChecks = ["apparmor-profiles", "apparmor-profiles-extra", "apparmor-utils"];
458
494
  sections.push("\n Profile Packages:");
459
495
  for (const pkg of pkgChecks) {
@@ -517,14 +553,25 @@ export function registerContainerSecurityTools(server) {
517
553
  const baseAction = action.replace("apparmor_", "");
518
554
  const cmdMap = { enforce: "aa-enforce", complain: "aa-complain", disable: "aa-disable" };
519
555
  const cmd = cmdMap[baseAction];
556
+ // Extract just the profile name from a potential path
557
+ const profileBaseName = profile.replace(/.*\//, "");
558
+ const isDesktopProfile = DESKTOP_BREAKING_PROFILES.has(profileBaseName);
559
+ // SAFETY: Warn when enforcing profiles known to break desktop apps
560
+ const desktopWarning = (baseAction === "enforce" && isDesktopProfile)
561
+ ? `\n\n⚠️ WARNING: Profile '${profileBaseName}' is known to break desktop applications ` +
562
+ `(flatpak, browsers, GUI apps) when enforced.\n` +
563
+ `These profiles use ABI 4.0 default-deny and block shared library loading.\n` +
564
+ `This may prevent Chromium, Firefox, Flatpak apps, and similar from launching.\n` +
565
+ `Consider using complain mode instead, or test thoroughly before enforcing.`
566
+ : "";
520
567
  if (dry_run ?? getConfig().dryRun) {
521
- return { content: [createTextContent(`[DRY RUN] Would set profile '${profile}' to ${baseAction} mode.\n Command: sudo ${cmd} ${profile}`)] };
568
+ return { content: [createTextContent(`[DRY RUN] Would set profile '${profile}' to ${baseAction} mode.\n Command: sudo ${cmd} ${profile}${desktopWarning}`)] };
522
569
  }
523
570
  const result = await executeCommand({ command: "sudo", args: [cmd, profile], toolName: "container_isolation", timeout: getToolTimeout("container_apparmor_manage") });
524
571
  if (result.exitCode !== 0)
525
572
  return { content: [createErrorContent(`Failed to ${baseAction} profile '${profile}': ${result.stderr}`)], isError: true };
526
- logChange(createChangeEntry({ tool: "container_isolation", action: baseAction, target: profile, after: `${baseAction} mode`, dryRun: false, success: true, rollbackCommand: baseAction === "disable" ? `sudo aa-enforce ${profile}` : undefined }));
527
- return { content: [createTextContent(`✅ Profile '${profile}' set to ${baseAction} mode.\n${result.stdout || result.stderr}`)] };
573
+ logChange(createChangeEntry({ tool: "container_isolation", action: baseAction, target: profile, after: `${baseAction} mode`, dryRun: false, success: true, rollbackCommand: baseAction === "disable" ? `sudo aa-enforce ${profile}` : baseAction === "enforce" ? `sudo aa-complain ${profile}` : undefined }));
574
+ return { content: [createTextContent(`✅ Profile '${profile}' set to ${baseAction} mode.\n${result.stdout || result.stderr}${desktopWarning}`)] };
528
575
  }
529
576
  catch (err) {
530
577
  return { content: [createErrorContent(err instanceof Error ? err.message : String(err))], isError: true };
@@ -537,13 +584,39 @@ export function registerContainerSecurityTools(server) {
537
584
  const isDryRun = dry_run ?? getConfig().dryRun;
538
585
  const packages = ["apparmor-profiles", "apparmor-profiles-extra"];
539
586
  if (isDryRun) {
540
- return { content: [createTextContent(`[DRY RUN] Would install: ${packages.join(", ")}\n Command: sudo apt-get install -y ${packages.join(" ")}`)] };
587
+ return { content: [createTextContent(`[DRY RUN] Would install: ${packages.join(", ")}\n` +
588
+ ` Command: sudo apt-get install -y ${packages.join(" ")}\n\n` +
589
+ `⚠️ After installation, the following profiles will be set to COMPLAIN mode\n` +
590
+ `to prevent breaking desktop applications (flatpak, browsers, etc.):\n` +
591
+ ` ${[...DESKTOP_BREAKING_PROFILES].join(", ")}\n\n` +
592
+ `Use apparmor_enforce to selectively enforce profiles after testing.`)] };
541
593
  }
542
594
  const installResult = await executeCommand({ command: "sudo", args: ["apt-get", "install", "-y", ...packages], toolName: "container_isolation", timeout: 120000 });
543
595
  if (installResult.exitCode !== 0)
544
596
  return { content: [createErrorContent(`Failed to install: ${installResult.stderr}`)], isError: true };
545
- logChange(createChangeEntry({ tool: "container_isolation", action: "install_profiles", target: packages.join(", "), after: "installed", dryRun: false, success: true, rollbackCommand: `sudo apt-get remove -y ${packages.join(" ")}` }));
546
- return { content: [createTextContent(`✅ Successfully installed: ${packages.join(", ")}`)] };
597
+ // SAFETY: Set desktop-breaking profiles to complain mode to prevent
598
+ // breaking flatpak, chromium, and other GUI applications.
599
+ // These profiles use ABI 4.0 default-deny with only `userns` granted,
600
+ // which blocks the dynamic linker from loading shared libraries.
601
+ const complainResults = [];
602
+ for (const profileName of DESKTOP_BREAKING_PROFILES) {
603
+ const profilePath = `/etc/apparmor.d/${profileName}`;
604
+ const checkResult = await executeCommand({ command: "test", args: ["-f", profilePath], toolName: "container_isolation", timeout: 5000 });
605
+ if (checkResult.exitCode === 0) {
606
+ const complainResult = await executeCommand({ command: "sudo", args: ["aa-complain", profilePath], toolName: "container_isolation", timeout: 10000 });
607
+ if (complainResult.exitCode === 0) {
608
+ complainResults.push(` ✓ ${profileName} → complain mode`);
609
+ }
610
+ else {
611
+ complainResults.push(` ✗ ${profileName} → failed: ${complainResult.stderr.trim()}`);
612
+ }
613
+ }
614
+ }
615
+ const complainSection = complainResults.length > 0
616
+ ? `\n\n⚠️ Desktop-safe profiles set to complain mode (prevents breaking GUI apps):\n${complainResults.join("\n")}\n\nUse apparmor_enforce to selectively enforce profiles after testing.`
617
+ : "";
618
+ logChange(createChangeEntry({ tool: "container_isolation", action: "install_profiles", target: packages.join(", "), after: `installed; ${complainResults.length} profiles set to complain`, dryRun: false, success: true, rollbackCommand: `sudo apt-get remove -y ${packages.join(" ")}` }));
619
+ return { content: [createTextContent(`✅ Successfully installed: ${packages.join(", ")}${complainSection}`)] };
547
620
  }
548
621
  catch (err) {
549
622
  return { content: [createErrorContent(err instanceof Error ? err.message : String(err))], isError: true };
@@ -1 +1 @@
1
- {"version":3,"file":"firewall.d.ts","sourceRoot":"","sources":["../../src/tools/firewall.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AA6EpE,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CA8+C7D"}
1
+ {"version":3,"file":"firewall.d.ts","sourceRoot":"","sources":["../../src/tools/firewall.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AA6EpE,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAigD7D"}
@@ -1315,14 +1315,36 @@ export function registerFirewallTools(server) {
1315
1315
  const ruleCount = (output.match(/^[A-Z]+\s/gm) || []).length;
1316
1316
  findings.push({ check: "iptables_rule_count", status: ruleCount > 0 ? "INFO" : "WARN", value: String(ruleCount), description: "Total iptables rules" });
1317
1317
  }
1318
- // Check UFW status
1319
- const ufwResult = await executeCommand({ command: "sudo", args: ["ufw", "status"], timeout: 10000, toolName: "firewall" });
1320
- if (ufwResult.exitCode === 0) {
1321
- const active = ufwResult.stdout.includes("Status: active");
1322
- findings.push({ check: "ufw_active", status: active ? "PASS" : "FAIL", value: active ? "active" : "inactive", description: "UFW firewall status" });
1318
+ // Check UFW status — distinguish "not installed" from "command failed"
1319
+ const ufwWhich = await executeCommand({ command: "which", args: ["ufw"], timeout: 5000, toolName: "firewall" });
1320
+ if (ufwWhich.exitCode === 0) {
1321
+ // UFW binary exists — try to get status
1322
+ const ufwResult = await executeCommand({ command: "sudo", args: ["ufw", "status"], timeout: 10000, toolName: "firewall" });
1323
+ if (ufwResult.exitCode === 0) {
1324
+ const active = ufwResult.stdout.includes("Status: active");
1325
+ findings.push({ check: "ufw_active", status: active ? "PASS" : "FAIL", value: active ? "active" : "inactive", description: "UFW firewall status" });
1326
+ }
1327
+ else {
1328
+ // UFW exists but status command failed — check nftables for UFW chains as fallback
1329
+ const nftFallback = await executeCommand({ command: "sudo", args: ["nft", "list", "ruleset"], timeout: 10000, toolName: "firewall" });
1330
+ const hasUfwChains = nftFallback.exitCode === 0 && nftFallback.stdout.includes("ufw-");
1331
+ if (hasUfwChains) {
1332
+ findings.push({ check: "ufw_active", status: "PASS", value: "active (nftables backend)", description: "UFW firewall status (detected via nftables ruleset)" });
1333
+ }
1334
+ else {
1335
+ findings.push({ check: "ufw_active", status: "WARN", value: "installed but status check failed", description: "UFW installed but 'ufw status' failed — may need sudo or service restart" });
1336
+ }
1337
+ }
1323
1338
  }
1324
1339
  else {
1325
- findings.push({ check: "ufw_installed", status: "FAIL", value: "not installed", description: "UFW firewall availability" });
1340
+ // UFW binary not found check if nftables has rules directly
1341
+ const nftDirect = await executeCommand({ command: "sudo", args: ["nft", "list", "ruleset"], timeout: 10000, toolName: "firewall" });
1342
+ if (nftDirect.exitCode === 0 && nftDirect.stdout.trim().length > 50) {
1343
+ findings.push({ check: "nftables_active", status: "PASS", value: "active (nftables native)", description: "Firewall active via nftables (no UFW)" });
1344
+ }
1345
+ else {
1346
+ findings.push({ check: "ufw_installed", status: "FAIL", value: "not installed", description: "No firewall detected (UFW not found, nftables empty)" });
1347
+ }
1326
1348
  }
1327
1349
  // Check ip6tables
1328
1350
  const ip6Result = await executeCommand({ command: "sudo", args: ["ip6tables", "-L", "-n"], timeout: 10000, toolName: "firewall" });
@@ -1 +1 @@
1
- {"version":3,"file":"hardening.d.ts","sourceRoot":"","sources":["../../src/tools/hardening.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAqTpE,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAwiF9D"}
1
+ {"version":3,"file":"hardening.d.ts","sourceRoot":"","sources":["../../src/tools/hardening.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AA+TpE,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAwiF9D"}
@@ -11,6 +11,7 @@ import { createTextContent, createErrorContent, parseSysctlOutput, parseSystemct
11
11
  import { logChange, createChangeEntry, backupFile, } from "../core/changelog.js";
12
12
  import { validateServiceName, sanitizeArgs, validateSysctlKey, } from "../core/sanitizer.js";
13
13
  import { SafeguardRegistry } from "../core/safeguards.js";
14
+ import { SudoSession } from "../core/sudo-session.js";
14
15
  import { readFileSync, existsSync } from "node:fs";
15
16
  import { execSync } from "node:child_process";
16
17
  import { spawnSafe } from "../core/spawn-safe.js";
@@ -26,7 +27,16 @@ function checkPrivileges() {
26
27
  if (typeof process.getuid === "function" && process.getuid() === 0) {
27
28
  return { ok: true };
28
29
  }
29
- // Check if sudo is available and the user has passwordless sudo
30
+ // Check if MCP sudo session is elevated (sudo -S with cached password)
31
+ try {
32
+ if (SudoSession.getInstance().isElevated()) {
33
+ return { ok: true };
34
+ }
35
+ }
36
+ catch {
37
+ // SudoSession not available — fall through to sudo -n check
38
+ }
39
+ // Fallback: Check if sudo is available without password (NOPASSWD)
30
40
  try {
31
41
  execSync("sudo -n true 2>/dev/null", { timeout: 3000, stdio: "ignore" });
32
42
  return { ok: true };
@@ -34,8 +44,8 @@ function checkPrivileges() {
34
44
  catch {
35
45
  return {
36
46
  ok: false,
37
- message: "Insufficient privileges. This operation requires root access or sudo. " +
38
- "Run this tool as root or ensure sudo is available without a password prompt.",
47
+ message: "Insufficient privileges. This operation requires root access or an active sudo session. " +
48
+ "Use sudo_session action=elevate_gui to authenticate first.",
39
49
  };
40
50
  }
41
51
  }
@@ -1 +1 @@
1
- {"version":3,"file":"meta.d.ts","sourceRoot":"","sources":["../../src/tools/meta.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAq5BpE,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAm/BzD"}
1
+ {"version":3,"file":"meta.d.ts","sourceRoot":"","sources":["../../src/tools/meta.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAq5BpE,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAohCzD"}
@@ -1123,12 +1123,46 @@ export function registerMetaTools(server) {
1123
1123
  const kernelScore = assessableKernelCount > 0 ? Math.round((kernelPassed / assessableKernelCount) * 100) : -1;
1124
1124
  domains.push({ domain: "kernel-hardening", score: kernelScore, maxScore: 100, checks: kernelResults.map((r) => ({ name: r.name, passed: r.passed, detail: r.detail })) });
1125
1125
  const fwChecks = [];
1126
- const iptResult = await executeCommand({ command: "iptables", args: ["-L", "-n"], timeout: 10000 });
1127
- const hasRules = iptResult.exitCode === 0 && iptResult.stdout.split("\n").length > 8;
1128
- fwChecks.push({ name: "iptables rules present", passed: hasRules, detail: `${iptResult.stdout.split("\n").length} lines` });
1129
- const ufwResult = await executeCommand({ command: "ufw", args: ["status"], timeout: 5000 });
1130
- const ufwActive = ufwResult.exitCode === 0 && ufwResult.stdout.includes("active");
1131
- fwChecks.push({ name: "UFW active", passed: ufwActive, detail: ufwResult.stdout.slice(0, 100) });
1126
+ // Check iptables/nftables rules (with sudo for accurate results)
1127
+ const iptResult = await executeCommand({ command: "sudo", args: ["iptables", "-L", "-n"], timeout: 10000 });
1128
+ const hasIptRules = iptResult.exitCode === 0 && iptResult.stdout.split("\n").length > 8;
1129
+ fwChecks.push({ name: "iptables rules present", passed: hasIptRules, detail: `${iptResult.exitCode === 0 ? iptResult.stdout.split("\n").length : 0} lines` });
1130
+ // Multi-layer firewall detection: UFW nftables fallback
1131
+ let fwDetected = false;
1132
+ const ufwResult = await executeCommand({ command: "sudo", args: ["ufw", "status"], timeout: 5000 });
1133
+ if (ufwResult.exitCode === 0) {
1134
+ const ufwActive = ufwResult.stdout.includes("active");
1135
+ fwChecks.push({ name: "UFW active", passed: ufwActive, detail: ufwResult.stdout.slice(0, 100) });
1136
+ fwDetected = ufwActive;
1137
+ }
1138
+ else {
1139
+ // UFW command failed — check if nftables has active rules (UFW chains or native)
1140
+ const nftResult = await executeCommand({ command: "sudo", args: ["nft", "list", "ruleset"], timeout: 10000 });
1141
+ if (nftResult.exitCode === 0) {
1142
+ const hasUfwChains = nftResult.stdout.includes("ufw-");
1143
+ const hasNftRules = nftResult.stdout.trim().length > 50;
1144
+ if (hasUfwChains) {
1145
+ fwChecks.push({ name: "UFW active", passed: true, detail: "Active via nftables backend (ufw chains detected)" });
1146
+ fwDetected = true;
1147
+ }
1148
+ else if (hasNftRules) {
1149
+ fwChecks.push({ name: "nftables active", passed: true, detail: "Native nftables ruleset loaded" });
1150
+ fwDetected = true;
1151
+ }
1152
+ else {
1153
+ fwChecks.push({ name: "UFW active", passed: false, detail: "No firewall rules detected" });
1154
+ }
1155
+ }
1156
+ else {
1157
+ // Check if binary exists to distinguish "not installed" from "error"
1158
+ const whichUfw = await executeCommand({ command: "which", args: ["ufw"], timeout: 3000 });
1159
+ const whichNft = await executeCommand({ command: "which", args: ["nft"], timeout: 3000 });
1160
+ const detail = whichUfw.exitCode === 0 ? "UFW installed but status check failed"
1161
+ : whichNft.exitCode === 0 ? "nftables installed but ruleset check failed"
1162
+ : "No firewall installed";
1163
+ fwChecks.push({ name: "firewall detected", passed: false, detail });
1164
+ }
1165
+ }
1132
1166
  const fwPassed = fwChecks.filter((c) => c.passed).length;
1133
1167
  domains.push({ domain: "firewall", score: Math.round((fwPassed / fwChecks.length) * 100), maxScore: 100, checks: fwChecks });
1134
1168
  const dangerousServices = ["telnet.socket", "rsh.socket", "rlogin.socket", "tftp.socket", "xinetd.service"];
@@ -1141,10 +1175,10 @@ export function registerMetaTools(server) {
1141
1175
  const svcPassed = svcChecks.filter((c) => c.passed).length;
1142
1176
  domains.push({ domain: "services", score: Math.round((svcPassed / svcChecks.length) * 100), maxScore: 100, checks: svcChecks });
1143
1177
  const userChecks = [];
1144
- const rootLogin = await executeCommand({ command: "passwd", args: ["-S", "root"], timeout: 5000 });
1178
+ const rootLogin = await executeCommand({ command: "sudo", args: ["passwd", "-S", "root"], timeout: 5000, toolName: "defense_mgmt" });
1145
1179
  const rootLocked = rootLogin.stdout.includes(" L ") || rootLogin.stdout.includes(" LK ");
1146
1180
  userChecks.push({ name: "Root account locked", passed: rootLocked, detail: rootLogin.stdout.trim().slice(0, 100) });
1147
- const noPasswd = await executeCommand({ command: "awk", args: ["-F:", '($2 == "" ) { print $1 }', "/etc/shadow"], timeout: 5000 });
1181
+ const noPasswd = await executeCommand({ command: "sudo", args: ["awk", "-F:", '($2 == "" ) { print $1 }', "/etc/shadow"], timeout: 5000, toolName: "defense_mgmt" });
1148
1182
  const noEmptyPasswd = noPasswd.stdout.trim().length === 0;
1149
1183
  userChecks.push({ name: "No empty passwords", passed: noEmptyPasswd, detail: noPasswd.stdout.trim() || "none" });
1150
1184
  const uidZero = await executeCommand({ command: "awk", args: ["-F:", '($3 == 0) { print $1 }', "/etc/passwd"], timeout: 5000 });
@@ -1 +1 @@
1
- {"version":3,"file":"sudo-management.d.ts","sourceRoot":"","sources":["../../src/tools/sudo-management.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAepE,wBAAgB,2BAA2B,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAitBnE"}
1
+ {"version":3,"file":"sudo-management.d.ts","sourceRoot":"","sources":["../../src/tools/sudo-management.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAgBpE,wBAAgB,2BAA2B,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAirBnE"}
@@ -16,6 +16,7 @@ import { z } from "zod";
16
16
  import { spawnSafe } from "../core/spawn-safe.js";
17
17
  import { SudoSession } from "../core/sudo-session.js";
18
18
  import { getConfig } from "../core/config.js";
19
+ import { resolveCommand } from "../core/command-allowlist.js";
19
20
  import { createTextContent, createErrorContent, } from "../core/parsers.js";
20
21
  import { invalidatePreflightCaches } from '../core/tool-wrapper.js';
21
22
  import { PreflightEngine } from '../core/preflight.js';
@@ -129,7 +130,7 @@ export function registerSudoManagementTools(server) {
129
130
  ` Auth method: password (sudo -S)`,
130
131
  ];
131
132
  if (isHeadless) {
132
- lines.push(` Environment: headless container (no display server)`);
133
+ lines.push(` Environment: headless (no display server)`);
133
134
  }
134
135
  lines.push(``);
135
136
  lines.push(`All tools that require sudo will now work automatically.`);
@@ -156,10 +157,8 @@ export function registerSudoManagementTools(server) {
156
157
  ? `Rate limit reached — locked out until ${rlAfter.resetAt ? new Date(rlAfter.resetAt).toLocaleTimeString() : "unknown"}.`
157
158
  : `Attempts remaining before lockout: ${rlAfter.attemptsRemaining}`;
158
159
  const passwordSource = isHeadless
159
- ? `\n\nNote: Running in headless container. The mcpuser password is set at\n` +
160
- `container startup via:\n` +
161
- ` • Docker secret: mcpuser-password (recommended)\n` +
162
- ` • Environment variable: MCPUSER_PASSWORD (less secure)`
160
+ ? `\n\nNote: Running in a headless environment (no display server).\n` +
161
+ `Use sudo_session action=elevate with your system password.`
163
162
  : "";
164
163
  return {
165
164
  content: [
@@ -184,39 +183,17 @@ export function registerSudoManagementTools(server) {
184
183
  }
185
184
  // ── elevate_gui ────────────────────────────────────────────────────
186
185
  //
187
- // Secure two-phase elevation flow:
188
- // Phase 1: LLM launches zenity via execute_command, password goes to temp file
189
- // (password NEVER appears in terminal output or LLM context)
190
- // Phase 2: LLM calls this action which reads file elevates securely wipes
186
+ // Single-step secure GUI elevation:
187
+ // 1. Detect graphical session environment (even if MCP server lacks DISPLAY)
188
+ // 2. Spawn a native password dialog (zenity/kdialog/ssh-askpass)
189
+ // 3. Capture password via secure temp file (never visible to AI)
190
+ // 4. Elevate and wipe the password
191
191
  //
192
192
  // The password is NEVER visible to the LLM at any point.
193
193
  case "elevate_gui": {
194
194
  const { timeout_minutes } = params;
195
195
  try {
196
- // ── Phase 4: Headless environment detection ───────────────────────
197
- // GUI dialogs (zenity, kdialog, ssh-askpass) require a display server.
198
- // In headless Docker containers, none of these are available.
199
- const hasDisplay = Boolean(process.env.DISPLAY) || Boolean(process.env.WAYLAND_DISPLAY);
200
- if (!hasDisplay) {
201
- return {
202
- content: [
203
- createErrorContent(`❌ GUI elevation is not available in headless environments.\n\n` +
204
- `The Defense MCP Server is running in a Docker container without\n` +
205
- `a display server (no DISPLAY or WAYLAND_DISPLAY is set).\n` +
206
- `Use sudo_session action=elevate instead:\n\n` +
207
- ` Action: elevate\n` +
208
- ` Parameter: password = <your sudo password>\n\n` +
209
- `The mcpuser password is set at container startup via:\n` +
210
- ` • Docker secret (recommended): docker run --secret mcpuser-password ...\n` +
211
- ` • Environment variable (less secure): docker run -e MCPUSER_PASSWORD='...' ...`),
212
- ],
213
- isError: true,
214
- };
215
- }
216
- const fs = await import("node:fs");
217
- const crypto = await import("node:crypto");
218
196
  const session = SudoSession.getInstance();
219
- const SUDO_PW_FILE = "/tmp/.defense-sudo-pw";
220
197
  // Check if already elevated
221
198
  if (session.isElevated()) {
222
199
  const status = session.getStatus();
@@ -229,117 +206,102 @@ export function registerSudoManagementTools(server) {
229
206
  ],
230
207
  };
231
208
  }
232
- // Check if the password file exists (Phase 2 of the two-phase flow)
233
- if (fs.existsSync(SUDO_PW_FILE)) {
234
- console.error("[sudo-gui] Phase 2: Reading password from secure temp file...");
235
- // Verify file ownership and permissions for safety
236
- const stat = fs.statSync(SUDO_PW_FILE);
237
- const mode = (stat.mode & 0o777).toString(8);
238
- if (mode !== "600") {
239
- // Wipe insecure file
240
- try {
241
- const size = stat.size || 64;
242
- fs.writeFileSync(SUDO_PW_FILE, crypto.randomBytes(size));
243
- fs.unlinkSync(SUDO_PW_FILE);
244
- }
245
- catch {
246
- console.error("[sudo-gui] Failed to wipe insecure password file");
247
- }
248
- return {
249
- content: [
250
- createErrorContent(`❌ Security error: Password file has insecure permissions (${mode}).\n` +
251
- `Expected 600. File has been securely wiped.\n` +
252
- `Please run the zenity command again.`),
253
- ],
254
- isError: true,
255
- };
256
- }
257
- // Read password from file
258
- let password;
259
- try {
260
- password = fs.readFileSync(SUDO_PW_FILE, "utf-8").trim();
261
- }
262
- catch (err) {
263
- return {
264
- content: [
265
- createErrorContent(`❌ Could not read password file: ${err instanceof Error ? err.message : String(err)}`),
266
- ],
267
- isError: true,
268
- };
269
- }
270
- // Securely wipe the file IMMEDIATELY (before elevation attempt)
271
- try {
272
- const fileSize = Buffer.byteLength(password, "utf-8") + 16;
273
- fs.writeFileSync(SUDO_PW_FILE, crypto.randomBytes(fileSize));
274
- fs.writeFileSync(SUDO_PW_FILE, crypto.randomBytes(fileSize)); // Double overwrite
275
- fs.unlinkSync(SUDO_PW_FILE);
276
- console.error("[sudo-gui] Password file securely wiped (2x random overwrite + unlink)");
277
- }
278
- catch {
279
- try {
280
- fs.unlinkSync(SUDO_PW_FILE);
281
- }
282
- catch {
283
- console.error("[sudo-gui] Failed to unlink password file during wipe fallback");
284
- }
285
- }
286
- if (!password || password.length === 0) {
287
- return {
288
- content: [
289
- createErrorContent(`❌ Empty password file. Please run the zenity command again and enter your password.`),
290
- ],
291
- isError: true,
292
- };
293
- }
294
- // Elevate using the captured password
295
- const timeoutMs = timeout_minutes * 60 * 1000;
296
- const config = getConfig();
297
- if (config.sudoSessionTimeout) {
298
- session.setDefaultTimeout(config.sudoSessionTimeout);
299
- }
300
- const result = await session.elevate(password, timeoutMs);
301
- if (result.success) {
302
- invalidatePreflightCaches();
303
- const status = session.getStatus();
304
- return {
305
- content: [
306
- createTextContent(`🔓 Privileges elevated successfully!\n\n` +
307
- ` User: ${status.username}\n` +
308
- ` Expires: ${status.expiresAt ?? "never (running as root)"}\n` +
309
- ` Timeout: ${timeout_minutes} minutes\n` +
310
- ` Method: Secure GUI dialog (password never visible to AI)\n\n` +
311
- `All tools that require sudo will now work automatically.\n` +
312
- `Use sudo_session action=status to check session state, or sudo_session action=drop to end early.`),
313
- ],
314
- };
315
- }
209
+ // ── Pre-flight rate-limit check ──────────────────────────────────
210
+ const rlStatus = session.getRateLimitStatus();
211
+ if (rlStatus.limited) {
212
+ const resetAt = rlStatus.resetAt
213
+ ? new Date(rlStatus.resetAt).toLocaleTimeString()
214
+ : "unknown";
316
215
  return {
317
216
  content: [
318
- createErrorContent(`❌ Elevation failed: ${result.error}\n\n` +
319
- `The password file was securely wiped. Please try again.`),
217
+ createErrorContent(`❌ Authentication rate limit exceeded.\n\n` +
218
+ `Too many failed attempts. Please wait until ${resetAt} before retrying.`),
320
219
  ],
321
220
  isError: true,
322
221
  };
323
222
  }
324
- // Phase 1: No password file found — instruct the LLM to launch the GUI dialog
325
- // Detect which GUI tool is available for the instruction
223
+ // ── Detect graphical session ──────────────────────────────────────
224
+ // The MCP server process may not inherit DISPLAY/WAYLAND_DISPLAY,
225
+ // so we probe the user's desktop session processes via /proc.
226
+ const sessionEnv = await getGraphicalSessionEnv();
227
+ const hasDisplay = Boolean(sessionEnv.DISPLAY) || Boolean(sessionEnv.WAYLAND_DISPLAY);
228
+ if (!hasDisplay) {
229
+ return {
230
+ content: [
231
+ createErrorContent(`❌ GUI elevation is not available — no graphical session detected.\n\n` +
232
+ `Could not find DISPLAY or WAYLAND_DISPLAY in the current process\n` +
233
+ `or any desktop session process (gnome-shell, plasmashell, etc.).\n\n` +
234
+ `Use sudo_session action=elevate with your password instead.`),
235
+ ],
236
+ isError: true,
237
+ };
238
+ }
239
+ // ── Detect available GUI dialog tool ─────────────────────────────
326
240
  const guiTool = await detectGuiPasswordTool();
327
- const toolCmd = guiTool
328
- ? `${guiTool.command} ${guiTool.args.map(a => `'${a}'`).join(" ")}`
329
- : "zenity --password --title='Defense — Sudo Authentication' --width=400";
241
+ if (!guiTool) {
242
+ return {
243
+ content: [
244
+ createErrorContent(`❌ No GUI password dialog tool found.\n\n` +
245
+ `Install one of: zenity, kdialog, or ssh-askpass\n` +
246
+ ` sudo apt install zenity # GNOME/GTK\n` +
247
+ ` sudo apt install kdialog # KDE/Qt\n\n` +
248
+ `Or use sudo_session action=elevate with your password instead.`),
249
+ ],
250
+ isError: true,
251
+ };
252
+ }
253
+ console.error(`[sudo-gui] Launching ${guiTool.name} password dialog...`);
254
+ // ── Launch GUI dialog and capture password ────────────────────────
255
+ // openGuiPasswordDialog spawns the dialog with the correct graphical
256
+ // session environment, captures the password via a secure temp file,
257
+ // and wipes it immediately after reading.
258
+ const password = await openGuiPasswordDialog(guiTool);
259
+ if (!password) {
260
+ return {
261
+ content: [
262
+ createErrorContent(`❌ Password dialog was cancelled or timed out.\n\n` +
263
+ `No password was entered. Try again with:\n` +
264
+ ` sudo_session action=elevate_gui\n\n` +
265
+ `Or provide your password directly with:\n` +
266
+ ` sudo_session action=elevate`),
267
+ ],
268
+ isError: true,
269
+ };
270
+ }
271
+ // ── Elevate using captured password ──────────────────────────────
272
+ const timeoutMs = timeout_minutes * 60 * 1000;
273
+ const config = getConfig();
274
+ if (config.sudoSessionTimeout) {
275
+ session.setDefaultTimeout(config.sudoSessionTimeout);
276
+ }
277
+ const result = await session.elevate(password, timeoutMs);
278
+ if (result.success) {
279
+ invalidatePreflightCaches();
280
+ const status = session.getStatus();
281
+ return {
282
+ content: [
283
+ createTextContent(`🔓 Privileges elevated successfully!\n\n` +
284
+ ` User: ${status.username}\n` +
285
+ ` Expires: ${status.expiresAt ?? "never (running as root)"}\n` +
286
+ ` Timeout: ${timeout_minutes} minutes\n` +
287
+ ` Method: Secure GUI dialog (${guiTool.name}) — password never visible to AI\n\n` +
288
+ `All tools that require sudo will now work automatically.\n` +
289
+ `Use sudo_session action=status to check session state, or sudo_session action=drop to end early.`),
290
+ ],
291
+ };
292
+ }
293
+ // ── Elevation failed ─────────────────────────────────────────────
294
+ const rlAfter = session.getRateLimitStatus();
295
+ const attemptsLine = rlAfter.limited
296
+ ? `Rate limit reached — locked out until ${rlAfter.resetAt ? new Date(rlAfter.resetAt).toLocaleTimeString() : "unknown"}.`
297
+ : `Attempts remaining before lockout: ${rlAfter.attemptsRemaining}`;
330
298
  return {
331
299
  content: [
332
- createTextContent(`🔐 SECURE ELEVATION — Step 1 of 2\n` +
333
- `${"═".repeat(50)}\n\n` +
334
- `To elevate securely, run this command via execute_command:\n\n` +
335
- ` ${toolCmd} > /tmp/.defense-sudo-pw 2>/dev/null && chmod 600 /tmp/.defense-sudo-pw && echo "READY" || echo "CANCELLED"\n\n` +
336
- `This opens a password dialog on the user's screen.\n` +
337
- `The password goes DIRECTLY to a secure temp file — it\n` +
338
- `NEVER appears in terminal output or the AI chat.\n\n` +
339
- `After the user enters their password (output shows "READY"),\n` +
340
- `call sudo_session action=elevate_gui again to complete elevation.\n` +
341
- `The tool will read the file, elevate, and securely wipe it.`),
300
+ createErrorContent(`❌ Authentication failed: ${result.error}\n\n` +
301
+ `${attemptsLine}\n\n` +
302
+ `The password was securely wiped. Please try again.`),
342
303
  ],
304
+ isError: true,
343
305
  };
344
306
  }
345
307
  catch (err) {
@@ -631,6 +593,7 @@ export function registerSudoManagementTools(server) {
631
593
  }
632
594
  /**
633
595
  * Detect which GUI password dialog tool is available.
596
+ * Uses the command allowlist for resolution (no spawning `which`).
634
597
  * Preference order: zenity > kdialog > ssh-askpass
635
598
  */
636
599
  async function detectGuiPasswordTool() {
@@ -663,17 +626,15 @@ async function detectGuiPasswordTool() {
663
626
  ];
664
627
  for (const tool of candidates) {
665
628
  try {
666
- const result = await new Promise((resolve) => {
667
- const child = spawnSafe("which", [tool.command], {
668
- stdio: ["ignore", "pipe", "pipe"],
669
- });
670
- child.on("close", (code) => resolve(code === 0));
671
- child.on("error", () => resolve(false));
672
- });
673
- if (result)
674
- return tool;
629
+ // resolveCommand checks the allowlist and verifies the binary exists on disk
630
+ const resolved = resolveCommand(tool.command);
631
+ if (resolved) {
632
+ console.error(`[sudo-gui] Found GUI tool: ${tool.name} at ${resolved}`);
633
+ return { ...tool, command: resolved };
634
+ }
675
635
  }
676
636
  catch {
637
+ // Binary not found or not in allowlist — try next candidate
677
638
  continue;
678
639
  }
679
640
  }
@@ -688,11 +649,28 @@ async function getGraphicalSessionEnv() {
688
649
  try {
689
650
  const { readFile } = await import("node:fs/promises");
690
651
  const { execFileSafe } = await import("../core/spawn-safe.js");
691
- // Find a PID from the user's graphical session (sddm-greeter, Xwayland, or the desktop itself)
652
+ // Find a PID from the user's graphical session.
653
+ // We need a process that INHERITS display vars from the compositor.
654
+ // The compositor itself (gnome-shell, kwin_wayland) often doesn't have
655
+ // DISPLAY/WAYLAND_DISPLAY in its own /proc/environ — so we prefer child
656
+ // processes like gjs, nautilus, plasmashell that do inherit them.
692
657
  const uid = process.getuid?.() ?? 1000;
693
- // Get a graphical session process PID owned by the current user
694
658
  let pid = null;
695
- const candidates = ["sddm", "kwin_wayland", "plasmashell", "gnome-shell", "Xwayland", "xfce4-session"];
659
+ const candidates = [
660
+ "gjs", // GNOME shell extensions (always has display vars)
661
+ "nautilus", // GNOME file manager
662
+ "plasmashell", // KDE
663
+ "kwin_wayland", // KDE compositor
664
+ "xfce4-panel", // XFCE
665
+ "xfce4-session", // XFCE
666
+ "cinnamon", // Cinnamon
667
+ "budgie-panel", // Budgie
668
+ "lxqt-panel", // LXQt
669
+ "sway", // Sway
670
+ "hyprland", // Hyprland
671
+ "Xwayland", // X11-on-Wayland bridge
672
+ "gnome-shell", // GNOME compositor (may lack display vars)
673
+ ];
696
674
  for (const proc of candidates) {
697
675
  try {
698
676
  const result = execFileSafe("pgrep", ["-u", String(uid), "-o", proc], { encoding: "utf-8", stdio: "pipe" }).trim();
@@ -850,5 +828,3 @@ async function openGuiPasswordDialog(tool) {
850
828
  catch { /* best-effort cleanup */ }
851
829
  }
852
830
  }
853
- // Suppress unused warning — openGuiPasswordDialog is kept for potential future use
854
- void openGuiPasswordDialog;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "defense-mcp-server",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
4
4
  "description": "Defense MCP Server — 31 domain-grouped defensive security tools for system hardening and threat detection",
5
5
  "type": "module",
6
6
  "main": "build/index.js",
@@ -28,7 +28,13 @@
28
28
  "changelog:check": "git diff HEAD~1 --name-only | grep -q CHANGELOG.md || echo 'Warning: CHANGELOG.md not updated'",
29
29
  "license:check": "license-checker --production --failOn 'GPL-3.0;AGPL-3.0'",
30
30
  "prepare": "husky",
31
- "prepublishOnly": "npm run build"
31
+ "prepublishOnly": "npm run build",
32
+ "release": "./scripts/release.sh patch",
33
+ "release:minor": "./scripts/release.sh minor",
34
+ "release:major": "./scripts/release.sh major",
35
+ "release:git": "./scripts/release.sh patch --git-only",
36
+ "release:npm": "./scripts/release.sh --npm-only",
37
+ "push": "git add -A && git commit -m 'chore: update' && git push origin $(git rev-parse --abbrev-ref HEAD)"
32
38
  },
33
39
  "keywords": [
34
40
  "mcp",