Class: KairosMcp::Daemon::RestrictedShell

Inherits:
Object
  • Object
show all
Defined in:
lib/kairos_mcp/daemon/restricted_shell.rb,
lib/kairos_mcp/daemon/restricted_shell/errors.rb,
lib/kairos_mcp/daemon/restricted_shell/runner.rb,
lib/kairos_mcp/daemon/restricted_shell/argv_validators.rb,
lib/kairos_mcp/daemon/restricted_shell/binary_resolver.rb,
lib/kairos_mcp/daemon/restricted_shell/sandbox_context.rb,
lib/kairos_mcp/daemon/restricted_shell/sandbox_factory.rb

Overview

RestrictedShell — sandboxed external binary execution.

Design (P3.4 v0.2):

4-layer defense: allowlist → argv validator → OS sandbox → timeout+kill
Network deny by default. Single-threaded, synchronous.

Defined Under Namespace

Modules: BinaryResolver, Runner, SandboxFactory Classes: BaseArgvValidator, GitArgvValidator, OutputTruncated, PandocArgvValidator, PolicyViolation, ResolverError, Result, SandboxContext, SandboxError, ShellError, TimeoutError, XelatexArgvValidator

Constant Summary collapse

MAX_STDIN_BYTES =

R2 residual: conservative limit to avoid pipe deadlock

8_192
DEFAULT_ENV_ALLOWLIST =
%w[PATH LANG LC_ALL].freeze
DEFAULT_MAX_OUTPUT =
4 * 1024 * 1024

Class Method Summary collapse

Class Method Details

.build_env(allowlist, short_name) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
# File 'lib/kairos_mcp/daemon/restricted_shell.rb', line 101

def self.build_env(allowlist, short_name)
  env = ENV.to_h.slice(*allowlist)
  if short_name == 'git'
    git_home = Dir.mktmpdir('kairos_git_home')
    env.merge!(
      # Config isolation
      'GIT_CONFIG_NOSYSTEM' => '1',       # no /etc/gitconfig
      'GIT_CONFIG_GLOBAL'   => '/dev/null', # no ~/.gitconfig (belt + suspenders with HOME)
      'HOME'               => git_home,    # empty home = no user config
      # Neutralize arbitrary command execution paths
      'GIT_TERMINAL_PROMPT' => '0',
      'PAGER'              => 'cat',
      'GIT_PAGER'          => 'cat',
      'GIT_DIFF_EXTERNAL'  => '',          # external diff program
      'GIT_EXTERNAL_DIFF'  => '',          # alternative external diff
      'GIT_SSH_COMMAND'    => '/usr/bin/true', # neutralize SSH
      'GIT_ASKPASS'        => '/usr/bin/true', # neutralize credential helpers
      'GIT_PROXY_COMMAND'  => '',           # no proxy
      'GIT_EDITOR'         => '/usr/bin/true', # no editor spawn
      # Attribute isolation (textconv, diff drivers from .gitattributes)
      'GIT_ATTR_NOSYSTEM'  => '1',         # no system gitattributes
    )
    env[:_git_home_tmpdir] = git_home  # cleanup handle (removed before spawn)
  end
  env
end

.run(cmd:, cwd:, timeout:, allowed_paths:, env_allowlist: DEFAULT_ENV_ALLOWLIST, network: :deny, stdin_data: nil, max_output_bytes: DEFAULT_MAX_OUTPUT) ⇒ Result

Parameters:

  • cmd (Array<String>)
    short_name, *argv
  • cwd (String)

    absolute path

  • timeout (Integer)

    wall seconds

  • allowed_paths (Array<String>)

    absolute paths for read+write

  • env_allowlist (Array<String>) (defaults to: DEFAULT_ENV_ALLOWLIST)

    env vars passed through

  • network (:deny, :allow) (defaults to: :deny)

    default :deny

  • stdin_data (String, nil) (defaults to: nil)

    piped to stdin

  • max_output_bytes (Integer) (defaults to: DEFAULT_MAX_OUTPUT)

    per-stream cap

Returns:

Raises:



45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
# File 'lib/kairos_mcp/daemon/restricted_shell.rb', line 45

def self.run(cmd:, cwd:, timeout:, allowed_paths:,
             env_allowlist: DEFAULT_ENV_ALLOWLIST,
             network: :deny, stdin_data: nil,
             max_output_bytes: DEFAULT_MAX_OUTPUT)
  cmd = Array(cmd).map(&:to_s)
  raise PolicyViolation, 'cmd must not be empty' if cmd.empty?
  raise PolicyViolation, 'cmd must be an Array' unless cmd.is_a?(Array)
  raise PolicyViolation, "stdin_data exceeds #{MAX_STDIN_BYTES} bytes" \
    if stdin_data && stdin_data.bytesize > MAX_STDIN_BYTES
  raise PolicyViolation, 'cwd must be absolute' unless cwd.start_with?('/')
  raise PolicyViolation, "network must be :deny or :allow" unless %i[deny allow].include?(network)

  short_name = cmd.first
  resolved = BinaryResolver.resolve!(short_name)
  resolved[:validator].validate!(cmd[1..])

  validate_paths!(cwd, allowed_paths)

  env = build_env(env_allowlist, short_name)
  git_home_tmpdir = env.delete(:_git_home_tmpdir)  # internal cleanup handle

  sandbox_ctx = SandboxFactory.wrap(
    bin_path: resolved[:path], argv: cmd[1..],
    cwd: cwd, allowed_paths: allowed_paths, network: network
  )

  begin
    Runner.run_with_timeout(
      wrapped_cmd: sandbox_ctx.cmd,
      env: env, cwd: cwd, timeout: timeout,
      stdin_data: stdin_data,
      max_output_bytes: max_output_bytes,
      cmd_for_hash: cmd,
      sandbox_driver: sandbox_ctx.driver
    )
  ensure
    sandbox_ctx.cleanup!
    # R2 residual: cleanup git temp HOME
    FileUtils.rm_rf(git_home_tmpdir) if git_home_tmpdir
  end
end

.validate_paths!(cwd, allowed_paths) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



88
89
90
91
92
93
94
95
96
97
98
# File 'lib/kairos_mcp/daemon/restricted_shell.rb', line 88

def self.validate_paths!(cwd, allowed_paths)
  allowed_paths.each do |p|
    raise PolicyViolation, "allowed_path must be absolute: #{p}" unless p.start_with?('/')
  end
  # cwd must be under one of allowed_paths
  # F1 fix: directory-boundary check (append / to prevent prefix bypass)
  cwd_real = File.expand_path(cwd) + '/'
  unless allowed_paths.any? { |p| cwd_real.start_with?(File.expand_path(p) + '/') }
    raise PolicyViolation, "cwd #{cwd} is not under any allowed_path"
  end
end