kward 0.78.0 → 0.79.0

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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +7 -2
  3. data/.github/workflows/pages.yml +1 -1
  4. data/CHANGELOG.md +24 -0
  5. data/Gemfile.lock +2 -2
  6. data/README.md +4 -2
  7. data/doc/agent-tools.md +1 -1
  8. data/doc/configuration.md +68 -2
  9. data/doc/extensibility.md +2 -0
  10. data/doc/getting-started.md +1 -1
  11. data/doc/local-models.md +130 -0
  12. data/doc/permissions.md +3 -2
  13. data/doc/releasing.md +1 -1
  14. data/doc/rpc.md +12 -1
  15. data/doc/sandboxing.md +120 -0
  16. data/doc/security.md +11 -5
  17. data/doc/skills.md +10 -0
  18. data/doc/workspace-tools.md +3 -3
  19. data/kward.gemspec +1 -1
  20. data/lib/kward/cli/doctor.rb +21 -0
  21. data/lib/kward/cli/runtime_helpers.rb +1 -1
  22. data/lib/kward/cli/settings.rb +66 -4
  23. data/lib/kward/cli/slash_commands.rb +109 -1
  24. data/lib/kward/cli.rb +2 -1
  25. data/lib/kward/config_files.rb +80 -1
  26. data/lib/kward/conversation.rb +14 -1
  27. data/lib/kward/local_command_runner.rb +2 -2
  28. data/lib/kward/model/client.rb +140 -4
  29. data/lib/kward/model/model_info.rb +11 -1
  30. data/lib/kward/model/payloads.rb +1 -0
  31. data/lib/kward/model/stream_parser.rb +58 -26
  32. data/lib/kward/pan/index.html.erb +50 -0
  33. data/lib/kward/pan/server.rb +49 -2
  34. data/lib/kward/plugin_registry.rb +1 -1
  35. data/lib/kward/prompt_interface/editor/controller.rb +36 -3
  36. data/lib/kward/prompt_interface.rb +18 -0
  37. data/lib/kward/prompts/commands.rb +2 -0
  38. data/lib/kward/prompts.rb +16 -5
  39. data/lib/kward/rpc/plugin_chat_manager.rb +4 -1
  40. data/lib/kward/rpc/server.rb +42 -4
  41. data/lib/kward/rpc/session_manager.rb +42 -8
  42. data/lib/kward/sandbox/capabilities.rb +39 -0
  43. data/lib/kward/sandbox/command_runner.rb +28 -0
  44. data/lib/kward/sandbox/environment.rb +24 -0
  45. data/lib/kward/sandbox/linux_bubblewrap_runner.rb +71 -0
  46. data/lib/kward/sandbox/macos_seatbelt_runner.rb +96 -0
  47. data/lib/kward/sandbox/passthrough_runner.rb +13 -0
  48. data/lib/kward/sandbox/policy.rb +74 -0
  49. data/lib/kward/sandbox/runner_factory.rb +55 -0
  50. data/lib/kward/sandbox/unavailable_runner.rb +21 -0
  51. data/lib/kward/sandbox.rb +9 -0
  52. data/lib/kward/session_store.rb +26 -0
  53. data/lib/kward/skills/capture.rb +144 -0
  54. data/lib/kward/tools/registry.rb +19 -4
  55. data/lib/kward/version.rb +1 -1
  56. data/lib/kward/workspace.rb +18 -3
  57. data/lib/kward/workspace_factory.rb +17 -0
  58. data/templates/default/fulldoc/html/js/kward.js +1 -0
  59. data/templates/default/kward_navigation.rb +4 -2
  60. data/templates/default/layout/html/setup.rb +1 -0
  61. metadata +16 -2
@@ -364,6 +364,24 @@ module Kward
364
364
  run_editor
365
365
  end
366
366
 
367
+ # Opens an in-memory document editor. The callback receives edited content
368
+ # when the user saves; return an error message to keep the editor open.
369
+ def review_document(title:, content:, &on_save)
370
+ raise ArgumentError, "review_document requires a save callback" unless on_save
371
+
372
+ start(render: false)
373
+ opened = @mutex.synchronize do
374
+ @editor_save_callback = on_save
375
+ open_scratchpad(:markdown, content: content).tap do
376
+ @editor_state.status = "#{title} · Ctrl+S save · Ctrl+Q cancel"
377
+ render_prompt_locked
378
+ end
379
+ end
380
+ return false unless opened
381
+
382
+ run_editor
383
+ end
384
+
367
385
  def run_editor
368
386
  loop do
369
387
  key = read_key(nonblock: true)
@@ -33,7 +33,9 @@ module Kward
33
33
  { name: "tab", description: "Manage tabs.", argument_hint: "[1-n|move|close|new|name]" },
34
34
  { name: "status", description: "Show the current status message.", argument_hint: "" },
35
35
  { name: "stats", description: "Show telemetry logging stats.", argument_hint: "[range]" },
36
+ { name: "sandbox", description: "Inspect or configure model command sandboxing.", argument_hint: "[status|off|read_only|workspace_write|network allow|network deny]" },
36
37
  { name: "hooks", description: "Inspect lifecycle hooks.", argument_hint: "[list|events|logs|doctor|trust|untrust]" },
38
+ { name: "skill", description: "Activate a skill or capture one from a saved session.", argument_hint: "<name>|capture" },
37
39
  { name: "memory", description: "Inspect and manage Kward memory.", argument_hint: "[enable|disable|auto-summary|core|add|list|forget|promote|relax|inspect|why|summarize]" }
38
40
  ].freeze
39
41
  BUILTIN_RESERVED_COMMAND_NAMES = BUILTIN_COMMANDS.map { |command| command[:name] }.freeze
data/lib/kward/prompts.rb CHANGED
@@ -38,6 +38,8 @@ module Kward
38
38
  # @return [Array<Hash>] section hashes with label, content, and optional source
39
39
  # @api public
40
40
  def prompt_sections(workspace_root: Dir.pwd, include_workspace_personality: true, model: nil, reasoning_effort: nil, now: Time.now, memory_context: nil, plugin_context: nil)
41
+ return replacement_prompt_sections if ConfigFiles.replacement_system_prompt?
42
+
41
43
  sections = [prompt_section("Built-in system prompt", base_prompt)]
42
44
  sections << prompt_section(config_agents_prompt_label, config_agents_prompt, source: config_agents_prompt_source)
43
45
  sections << prompt_section("Memory context", memory_context) unless memory_context.to_s.empty?
@@ -53,11 +55,11 @@ module Kward
53
55
 
54
56
  def base_prompt
55
57
  <<~PROMPT.strip
56
- You are Kward, a concise practical CLI coding agent. Use tools to understand and modify software projects. Inspect files before changing them, make the smallest correct change, preserve existing style, and summarize what changed. Be honest about limitations.
58
+ You are Kward, a concise practical CLI coding agent. Use tools to understand and modify software projects. Only call tools advertised for the current turn. Inspect files before changing them, make the smallest correct change, preserve existing style, and summarize what changed. Be honest about limitations.
57
59
 
58
- For web research, use web_search to discover sources, fetch_content for important human-readable pages, and fetch_raw for machine-readable resources such as JSON, YAML, XML, RSS, OpenAPI specs, and plain text. Prefer official or primary sources and cite or mention the URLs you relied on.
60
+ When web tools are available, use web_search to discover sources, fetch_content for important human-readable pages, and fetch_raw for machine-readable resources such as JSON, YAML, XML, RSS, OpenAPI specs, and plain text. Prefer official or primary sources and cite or mention the URLs you relied on. Use code_search for package, GitHub repository, and source-code research.
59
61
 
60
- Manage code context deliberately. Prefer context_for_task, summarize_file_structure, and read_file mode="outline"/"preview" before broad reads. Escalate to read_file mode="range" for exact lines, and use mode="full" only when focused context is insufficient. Use context_budget_stats when asked about context savings.
62
+ Manage code context deliberately. Prefer context_for_task, summarize_file_structure, and read_file mode="outline"/"preview" before broad reads. Escalate to read_file mode="range" for exact lines, and use mode="full" only when focused context is insufficient. If output is compacted or replaced with a duplicate reference, use retrieve_tool_output to inspect the needed original detail. Use context_budget_stats when asked about context savings. When a material requirement is ambiguous, use ask_user_question when it is available; otherwise state the assumption.
61
63
  PROMPT
62
64
  end
63
65
 
@@ -73,6 +75,7 @@ module Kward
73
75
  end
74
76
 
75
77
  def config_agents_prompt_source
78
+ return nil unless ConfigFiles.include_config_principles?
76
79
  return ConfigFiles.config_principles_path if File.exist?(ConfigFiles.config_principles_path)
77
80
  return ConfigFiles.config_agents_path if File.exist?(ConfigFiles.config_agents_path)
78
81
 
@@ -112,6 +115,14 @@ module Kward
112
115
  "Workspace AGENTS.md hint"
113
116
  end
114
117
 
118
+ def replacement_prompt_sections
119
+ path = ConfigFiles.system_prompt_file_path
120
+ content = ConfigFiles.system_prompt_file
121
+ return [] if content.to_s.empty?
122
+
123
+ [prompt_section("Custom system prompt (replacement)", content, source: path)]
124
+ end
125
+
115
126
  def prompt_section(label, content, source: nil)
116
127
  return nil if content.to_s.empty?
117
128
 
@@ -123,8 +134,8 @@ module Kward
123
134
 
124
135
  lines = [
125
136
  "Agent Skills are available.",
126
- "When a task matches a skill's description, use read_skill to load its instructions before proceeding.",
127
- "Use read_skill with a relative path to read referenced files inside a skill directory.",
137
+ "When read_skill is available and a task matches a skill's description, use it to load the instructions before proceeding.",
138
+ "When using read_skill, use a relative path to read referenced files inside a skill directory.",
128
139
  "Available skills:"
129
140
  ]
130
141
  skills.each do |skill|
@@ -23,9 +23,10 @@ module Kward
23
23
  Chat = Struct.new(:id, :type, :driver, :queue, :worker, :running_turn_id, keyword_init: true)
24
24
  Turn = Struct.new(:id, :chat_id, :input, :display_input, :status, :cancellation, :created_at, :started_at, :finished_at, :events, :next_sequence, :error, :mutex, keyword_init: true)
25
25
 
26
- def initialize(server:, client: Client.new)
26
+ def initialize(server:, client: Client.new, plugin_registry_provider: nil)
27
27
  @server = server
28
28
  @client = client
29
+ @plugin_registry_provider = plugin_registry_provider
29
30
  @chats = {}
30
31
  @turns = {}
31
32
  @subscriptions = {}
@@ -132,6 +133,8 @@ module Kward
132
133
  private
133
134
 
134
135
  def plugin_registry
136
+ return @plugin_registry_provider.call if @plugin_registry_provider
137
+
135
138
  @plugin_registry ||= PluginRegistry.load
136
139
  end
137
140
 
@@ -5,7 +5,7 @@ require_relative "../memory/manager"
5
5
  require_relative "../plugin_registry"
6
6
  require_relative "../prompts/commands"
7
7
  require_relative "../tools/registry"
8
- require_relative "../workspace"
8
+ require_relative "../workspace_factory"
9
9
  require_relative "../telemetry/logger"
10
10
  require_relative "../telemetry/stats"
11
11
  require_relative "auth_manager"
@@ -73,6 +73,7 @@ module Kward
73
73
  "memory/why", "memory/summarize"
74
74
  ].freeze
75
75
  COMMAND_METHODS = ["commands/list", "commands/run"].freeze
76
+ SKILL_CAPTURE_METHODS = ["skills/captureSessions", "skills/captureDraft", "skills/saveCapturedDraft"].freeze
76
77
  STARTUP_RESOURCE_METHODS = ["resources/startup"].freeze
77
78
  CONFIG_METHODS = ["config/read", "config/update"].freeze
78
79
  LOGGING_METHODS = ["logging/stats", "logging/tokenCsv"].freeze
@@ -101,6 +102,7 @@ module Kward
101
102
  auth: AUTH_METHODS,
102
103
  memory: MEMORY_METHODS,
103
104
  commands: COMMAND_METHODS,
105
+ skill_capture: SKILL_CAPTURE_METHODS,
104
106
  startup_resources: STARTUP_RESOURCE_METHODS,
105
107
  config: CONFIG_METHODS,
106
108
  logging: LOGGING_METHODS,
@@ -122,7 +124,11 @@ module Kward
122
124
  @client = client
123
125
  @config_manager = ConfigManager.new
124
126
  @session_manager = SessionManager.new(server: self, client: client, config_manager: @config_manager)
125
- @plugin_chat_manager = PluginChatManager.new(server: self, client: client)
127
+ @plugin_chat_manager = PluginChatManager.new(
128
+ server: self,
129
+ client: client,
130
+ plugin_registry_provider: -> { @session_manager.plugin_registry }
131
+ )
126
132
  @auth_manager = AuthManager.new(server: self, config_manager: @config_manager)
127
133
  @shutdown = false
128
134
  end
@@ -247,6 +253,12 @@ module Kward
247
253
  commands_list(params)
248
254
  when COMMAND_METHODS[1]
249
255
  commands_run(params)
256
+ when SKILL_CAPTURE_METHODS[0]
257
+ { sessions: @session_manager.skill_capture_sessions }
258
+ when SKILL_CAPTURE_METHODS[1]
259
+ @session_manager.capture_skill(session_path: params.fetch("sessionPath"))
260
+ when SKILL_CAPTURE_METHODS[2]
261
+ @session_manager.save_captured_skill(content: params.fetch("content"), overwrite: params["overwrite"] == true)
250
262
  when STARTUP_RESOURCE_METHODS[0]
251
263
  startup_resources(params)
252
264
  when CONFIG_METHODS[0]
@@ -520,6 +532,7 @@ module Kward
520
532
  memory: { supported: true, optIn: true, defaultEnabled: false, autoSummaryDefaultEnabled: false, promptInjection: "interactive", storage: { core: "json", soft: "jsonl", events: "jsonl" }, methods: MEMORY_METHODS },
521
533
  stability: { protocol: "stable", compatibility: "additive-fields-unless-protocol-version-changes", experimentalCapabilities: [] },
522
534
  commands: { supported: true, methods: COMMAND_METHODS, method: COMMAND_METHODS[0], runMethod: COMMAND_METHODS[1], sources: ["builtin", "prompt", "skill", "plugin"], executableSources: ["builtin", "plugin"] },
535
+ skillCapture: { supported: true, methods: SKILL_CAPTURE_METHODS, destination: "personal", source: "savedSessionActiveLeaf", reviewRequired: true, overwrite: "explicit", autoActivate: false },
523
536
  mcp: {
524
537
  supported: true,
525
538
  transport: "stdio",
@@ -556,7 +569,8 @@ module Kward
556
569
  workspaceMutationGuard: "none",
557
570
  toolApproval: { supported: true, defaultMode: "none", modes: ["none", "ask"], requestNotification: TOOL_APPROVAL_NOTIFICATION, answerMethod: TOOL_APPROVAL_METHODS.first },
558
571
  canRunShell: true,
559
- canWriteFiles: true
572
+ canWriteFiles: true,
573
+ sandbox: sandbox_capabilities
560
574
  },
561
575
  export: { supported: true, formats: ["markdown", "html"], defaultFormat: "markdown" },
562
576
  logging: {
@@ -576,6 +590,30 @@ module Kward
576
590
  }
577
591
  end
578
592
 
593
+ def sandbox_capabilities
594
+ config = @config_manager.read(redacted: false)
595
+ policy = ConfigFiles.sandbox_policy(Dir.pwd, config)
596
+ runner = Sandbox::RunnerFactory.build(policy)
597
+ runner.capabilities.to_h.merge(
598
+ mode: policy.mode,
599
+ network: policy.network,
600
+ scope: "shellCommandsOnly",
601
+ sessionPinning: false,
602
+ oneTimeElevation: false
603
+ )
604
+ rescue ArgumentError => error
605
+ {
606
+ available: false,
607
+ filesystemEnforced: false,
608
+ childNetworkEnforced: false,
609
+ backend: "invalidConfiguration",
610
+ reason: error.message,
611
+ scope: "shellCommandsOnly",
612
+ sessionPinning: false,
613
+ oneTimeElevation: false
614
+ }
615
+ end
616
+
579
617
  def workspace_info(root)
580
618
  root = @session_manager.validate_workspace_root(root)
581
619
  { root: root, basename: File.basename(root), writable: File.writable?(root) }
@@ -797,7 +835,7 @@ module Kward
797
835
  end
798
836
 
799
837
  def configured_workspace(root = Dir.pwd)
800
- Workspace.new(root: root, guardrails: workspace_guardrails_enabled?)
838
+ WorkspaceFactory.build(root: root, guardrails: workspace_guardrails_enabled?, config: @config_manager.read(redacted: false))
801
839
  end
802
840
 
803
841
  def workspace_guardrails_enabled?
@@ -15,6 +15,7 @@ require_relative "../memory/turn_context"
15
15
  require_relative "../message_access"
16
16
  require_relative "../message_text"
17
17
  require_relative "../session_tree_tool_display"
18
+ require_relative "../skills/capture"
18
19
  require_relative "../model/model_info"
19
20
  require_relative "../plugin_registry"
20
21
  require_relative "../prompts/commands"
@@ -25,7 +26,7 @@ require_relative "../steering"
25
26
  require_relative "../tools/tool_call"
26
27
  require_relative "../tools/registry"
27
28
  require_relative "../transcript_export"
28
- require_relative "../workspace"
29
+ require_relative "../workspace_factory"
29
30
  require_relative "attachment_normalizer"
30
31
  require_relative "config_manager"
31
32
  require_relative "memory_methods"
@@ -155,6 +156,38 @@ module Kward
155
156
  { sessions: @mutex.synchronize { @sessions.values.map { |rpc_session| session_payload(rpc_session) } } }
156
157
  end
157
158
 
159
+ def skill_capture_sessions
160
+ SessionStore.new(config_dir: @config_dir).capture_candidates.map do |session|
161
+ {
162
+ path: session.path,
163
+ workspaceRoot: session.cwd,
164
+ name: session.name,
165
+ firstMessage: session.first_message,
166
+ modifiedAt: session.modified_at.utc.iso8601(3)
167
+ }
168
+ end
169
+ end
170
+
171
+ def capture_skill(session_path:)
172
+ capture = Skills::Capture.new(
173
+ session_store: SessionStore.new(config_dir: @config_dir),
174
+ client: @client,
175
+ config_dir: @config_dir
176
+ )
177
+ draft = capture.generate(session_path)
178
+ { content: draft.content, sourcePath: draft.source_path, name: draft.name, description: draft.description }
179
+ end
180
+
181
+ def save_captured_skill(content:, overwrite: false)
182
+ capture = Skills::Capture.new(
183
+ session_store: SessionStore.new(config_dir: @config_dir),
184
+ client: @client,
185
+ config_dir: @config_dir
186
+ )
187
+ draft = capture.save(content, overwrite: overwrite)
188
+ { path: capture.skill_path(draft.name), name: draft.name, description: draft.description }
189
+ end
190
+
158
191
  def tool_schemas(session_id: nil)
159
192
  registry = if session_id
160
193
  fetch_session(session_id).tool_registry
@@ -164,6 +197,11 @@ module Kward
164
197
  { tools: registry.schemas }
165
198
  end
166
199
 
200
+ # Returns the plugin registry shared by RPC sessions and plugin chats.
201
+ def plugin_registry
202
+ @plugin_registry ||= PluginRegistry.load(reserved_commands: reserved_plugin_command_names)
203
+ end
204
+
167
205
  # Renames the persisted session attached to an RPC session id.
168
206
  def rename_session(session_id:, name:)
169
207
  rpc_session = fetch_session(session_id)
@@ -919,10 +957,6 @@ module Kward
919
957
  end
920
958
  end
921
959
 
922
- def plugin_registry
923
- @plugin_registry ||= PluginRegistry.load(reserved_commands: reserved_plugin_command_names)
924
- end
925
-
926
960
  def reserved_plugin_command_names
927
961
  PromptCommands::BUILTIN_RESERVED_COMMAND_NAMES + ConfigFiles.prompt_templates(reserved_commands: PromptCommands::BUILTIN_RESERVED_COMMAND_NAMES).map(&:command)
928
962
  end
@@ -1019,7 +1053,7 @@ module Kward
1019
1053
  end
1020
1054
 
1021
1055
  def configured_workspace(root)
1022
- Workspace.new(root: root, guardrails: workspace_guardrails_enabled?)
1056
+ WorkspaceFactory.build(root: root, guardrails: workspace_guardrails_enabled?, config: @config_manager.read(redacted: false))
1023
1057
  end
1024
1058
 
1025
1059
  def workspace_guardrails_enabled?
@@ -1161,6 +1195,8 @@ module Kward
1161
1195
  started, event = turn.mutex.synchronize do
1162
1196
  next [false, nil] if turn.status == "canceled"
1163
1197
 
1198
+ rpc_session.running_turn_id = turn.id
1199
+ turn.steering = build_steering(turn) if supports_in_flight_steer? && !turn.plugin_command_name
1164
1200
  turn.status = "running"
1165
1201
  turn.started_at = now
1166
1202
  [true, append_turn_event_locked(turn, "turnStarted", { status: "running" })]
@@ -1168,8 +1204,6 @@ module Kward
1168
1204
  return unless started
1169
1205
 
1170
1206
  @server.notify("turn/event", event)
1171
- rpc_session.running_turn_id = turn.id
1172
- turn.steering = build_steering(turn) if supports_in_flight_steer? && !turn.plugin_command_name
1173
1207
 
1174
1208
  if turn.cancel_requested
1175
1209
  finish_turn(turn, "canceled")
@@ -0,0 +1,39 @@
1
+ # Namespace for operating-system command sandboxing.
2
+ module Kward
3
+ module Sandbox
4
+ # Describes the enforcement a platform runner can provide.
5
+ class Capabilities
6
+ attr_reader :backend, :reason
7
+
8
+ def initialize(available:, filesystem_enforced:, child_network_enforced:, backend:, reason: nil)
9
+ @available = available == true
10
+ @filesystem_enforced = filesystem_enforced == true
11
+ @child_network_enforced = child_network_enforced == true
12
+ @backend = backend.to_s
13
+ @reason = reason&.to_s
14
+ end
15
+
16
+ def available?
17
+ @available
18
+ end
19
+
20
+ def filesystem_enforced?
21
+ @filesystem_enforced
22
+ end
23
+
24
+ def child_network_enforced?
25
+ @child_network_enforced
26
+ end
27
+
28
+ def to_h
29
+ {
30
+ available: available?,
31
+ filesystemEnforced: filesystem_enforced?,
32
+ childNetworkEnforced: child_network_enforced?,
33
+ backend: backend,
34
+ reason: reason
35
+ }.compact
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,28 @@
1
+ require_relative "../local_command_runner"
2
+
3
+ # Namespace for operating-system command sandboxing.
4
+ module Kward
5
+ module Sandbox
6
+ # Base command runner. Platform runners provide an argv that is then executed
7
+ # through LocalCommandRunner, preserving Kward's timeout and cancellation behavior.
8
+ class CommandRunner
9
+ def initialize(policy:, capabilities:)
10
+ @policy = policy
11
+ @capabilities = capabilities
12
+ end
13
+
14
+ attr_reader :policy, :capabilities
15
+
16
+ def run(command, cwd:, timeout_seconds:, max_output_bytes:, cancellation: nil, &block)
17
+ LocalCommandRunner.new(
18
+ timeout_seconds: timeout_seconds,
19
+ max_output_bytes: max_output_bytes
20
+ ).run(*command_argv(command, cwd: cwd), cwd: cwd, cancellation: cancellation, &block)
21
+ end
22
+
23
+ def command_argv(command, cwd:)
24
+ raise NotImplementedError, "#{self.class} must implement #command_argv"
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,24 @@
1
+ # Namespace for operating-system command sandboxing.
2
+ module Kward
3
+ module Sandbox
4
+ # Builds a minimal environment for sandboxed command workers. In particular,
5
+ # credentials and language-runtime injection variables are not inherited.
6
+ module Environment
7
+ SAFE_VARIABLES = %w[PATH LANG LC_ALL LC_CTYPE TERM COLORTERM NO_COLOR].freeze
8
+
9
+ module_function
10
+
11
+ def command_worker(temporary_root, source: ENV)
12
+ SAFE_VARIABLES.each_with_object({}) do |name, environment|
13
+ value = source[name].to_s
14
+ environment[name] = value unless value.empty?
15
+ end.merge(
16
+ "HOME" => temporary_root,
17
+ "TMPDIR" => temporary_root,
18
+ "TMP" => temporary_root,
19
+ "TEMP" => temporary_root
20
+ )
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,71 @@
1
+ require "fileutils"
2
+ require "tmpdir"
3
+ require_relative "command_runner"
4
+ require_relative "environment"
5
+
6
+ # Namespace for operating-system command sandboxing.
7
+ module Kward
8
+ module Sandbox
9
+ # Runs a command in a Bubblewrap mount namespace on Linux.
10
+ class LinuxBubblewrapRunner < CommandRunner
11
+ def self.executable
12
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).filter_map do |directory|
13
+ path = File.expand_path("bwrap", directory)
14
+ path if File.file?(path) && File.executable?(path)
15
+ end.first
16
+ end
17
+
18
+ def self.capabilities(platform: RUBY_PLATFORM, executable: self.executable)
19
+ available = platform.to_s.include?("linux") && !executable.to_s.empty?
20
+ return Capabilities.new(available: true, filesystem_enforced: true, child_network_enforced: true, backend: "linux_bubblewrap") if available
21
+
22
+ Capabilities.new(
23
+ available: false,
24
+ filesystem_enforced: false,
25
+ child_network_enforced: false,
26
+ backend: "linux_bubblewrap",
27
+ reason: "Bubblewrap is unavailable"
28
+ )
29
+ end
30
+
31
+ def initialize(policy:, executable: self.class.executable, capabilities: self.class.capabilities(executable: executable))
32
+ raise ArgumentError, "Linux Bubblewrap is unavailable: #{capabilities.reason}" unless capabilities.available?
33
+
34
+ super(policy:, capabilities:)
35
+ @executable = executable
36
+ end
37
+
38
+ def run(command, cwd:, timeout_seconds:, max_output_bytes:, cancellation: nil, &block)
39
+ temporary_root = Dir.mktmpdir("kward-sandbox")
40
+ environment = Environment.command_worker(temporary_root)
41
+
42
+ LocalCommandRunner.new(
43
+ timeout_seconds: timeout_seconds,
44
+ max_output_bytes: max_output_bytes
45
+ ).run(
46
+ *command_argv(command, cwd: cwd, temporary_root: temporary_root),
47
+ env: environment,
48
+ cwd: cwd,
49
+ cancellation: cancellation,
50
+ unsetenv_others: true,
51
+ &block
52
+ )
53
+ ensure
54
+ FileUtils.remove_entry(temporary_root) if temporary_root && File.exist?(temporary_root)
55
+ end
56
+
57
+ def command_argv(command, cwd:, temporary_root: nil)
58
+ temporary_root ||= Dir.tmpdir
59
+ workspace_root = policy.workspace_root
60
+ writable_roots = policy.command_writable_roots + [File.realpath(temporary_root)]
61
+ argv = [@executable, "--die-with-parent", "--new-session", "--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc"]
62
+ argv << "--unshare-net" unless policy.child_network_allowed?
63
+ argv.concat(["--bind", workspace_root, workspace_root]) if policy.workspace_write?
64
+ writable_roots.reject { |path| path == workspace_root }.each { |path| argv.concat(["--bind", path, path]) }
65
+ argv.concat(["--ro-bind", File.join(workspace_root, ".git"), File.join(workspace_root, ".git")]) if policy.protect_git_metadata? && File.exist?(File.join(workspace_root, ".git"))
66
+ argv.concat(["--setenv", "TMPDIR", temporary_root, "--setenv", "TMP", temporary_root, "--setenv", "TEMP", temporary_root])
67
+ argv.concat(["--chdir", cwd.to_s, "--", "/bin/sh", "-lc", command.to_s])
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,96 @@
1
+ require "fileutils"
2
+ require "tmpdir"
3
+ require_relative "command_runner"
4
+ require_relative "environment"
5
+
6
+ # Namespace for operating-system command sandboxing.
7
+ module Kward
8
+ module Sandbox
9
+ # Runs a command under macOS Seatbelt. This backend deliberately constrains
10
+ # writes and child networking first; host-process and plugin access remain
11
+ # outside the command worker boundary.
12
+ class MacOSSeatbeltRunner < CommandRunner
13
+ EXECUTABLE = "/usr/bin/sandbox-exec".freeze
14
+ PROTECTED_READ_DIRECTORIES = %w[.aws .gnupg .kward .ssh .config/gcloud .config/gh].freeze
15
+
16
+ def self.capabilities(platform: RUBY_PLATFORM)
17
+ available = platform.to_s.include?("darwin") && File.executable?(EXECUTABLE)
18
+ return Capabilities.new(available: true, filesystem_enforced: true, child_network_enforced: true, backend: "macos_seatbelt") if available
19
+
20
+ Capabilities.new(
21
+ available: false,
22
+ filesystem_enforced: false,
23
+ child_network_enforced: false,
24
+ backend: "macos_seatbelt",
25
+ reason: "#{EXECUTABLE} is unavailable"
26
+ )
27
+ end
28
+
29
+ def initialize(policy:, capabilities: self.class.capabilities)
30
+ raise ArgumentError, "macOS Seatbelt is unavailable: #{capabilities.reason}" unless capabilities.available?
31
+
32
+ super
33
+ end
34
+
35
+ def run(command, cwd:, timeout_seconds:, max_output_bytes:, cancellation: nil, &block)
36
+ temporary_root = Dir.mktmpdir("kward-sandbox")
37
+ environment = Environment.command_worker(temporary_root)
38
+
39
+ LocalCommandRunner.new(
40
+ timeout_seconds: timeout_seconds,
41
+ max_output_bytes: max_output_bytes
42
+ ).run(
43
+ *command_argv(command, cwd: cwd, temporary_root: temporary_root),
44
+ env: environment,
45
+ cwd: cwd,
46
+ cancellation: cancellation,
47
+ unsetenv_others: true,
48
+ &block
49
+ )
50
+ ensure
51
+ FileUtils.remove_entry(temporary_root) if temporary_root && File.exist?(temporary_root)
52
+ end
53
+
54
+ def command_argv(command, cwd:, temporary_root: nil)
55
+ temporary_root ||= Dir.tmpdir
56
+ [EXECUTABLE, "-p", profile(temporary_root), "/bin/zsh", "-lc", command.to_s]
57
+ end
58
+
59
+ private
60
+
61
+ def profile(temporary_root)
62
+ writable_roots = policy.command_writable_roots + [File.realpath(temporary_root)]
63
+ rules = [
64
+ "(version 1)",
65
+ "(deny default)",
66
+ "(allow process*)",
67
+ "(allow signal)",
68
+ "(allow file-read*)",
69
+ "(allow file-write-data (literal \"/dev/null\"))",
70
+ "(allow sysctl-read)",
71
+ "(allow mach-lookup)",
72
+ "(allow ipc-posix-shm*)",
73
+ "(allow user-preference-read)",
74
+ "(allow pseudo-tty)"
75
+ ]
76
+ rules << "(allow network*)" if policy.child_network_allowed?
77
+ rules.concat(writable_roots.map { |path| "(allow file-write* (subpath #{seatbelt_string(path)}))" })
78
+ rules.concat(protected_read_roots.map { |path| "(deny file-read* (subpath #{seatbelt_string(path)}))" })
79
+ rules << "(deny file-write* (subpath #{seatbelt_string(File.join(policy.workspace_root, ".git"))}))" if policy.protect_git_metadata?
80
+ rules.join("\n")
81
+ end
82
+
83
+ def protected_read_roots
84
+ home = ENV.fetch("HOME", Dir.home)
85
+ PROTECTED_READ_DIRECTORIES.map do |path|
86
+ root = File.join(home, path)
87
+ File.exist?(root) ? File.realpath(root) : root
88
+ end
89
+ end
90
+
91
+ def seatbelt_string(value)
92
+ %Q{"#{value.to_s.gsub(/([\\"])/, '\\\\1')}"}
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,13 @@
1
+ require_relative "command_runner"
2
+
3
+ # Namespace for operating-system command sandboxing.
4
+ module Kward
5
+ module Sandbox
6
+ # Preserves existing LocalCommandRunner behavior when sandboxing is off.
7
+ class PassthroughRunner < CommandRunner
8
+ def command_argv(command, cwd:)
9
+ [command.to_s]
10
+ end
11
+ end
12
+ end
13
+ end