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
data/doc/skills.md CHANGED
@@ -42,6 +42,16 @@ The `name` and `description` fields are required. The description matters: Kward
42
42
 
43
43
  Skill names should be lowercase letters, numbers, and hyphens, such as `testing`, `code-review`, or `release-checklist`.
44
44
 
45
+ ## Capture a skill from a session
46
+
47
+ Use `/skill capture` after a useful session. Choose any saved session; Kward uses its active branch and asks your active model to draft a reusable `SKILL.md`. Review and edit the draft in the terminal editor, then save it explicitly. Captured skills always go to your personal Kward skill directory and are not activated automatically.
48
+
49
+ The draft request includes the complete persisted branch, including available raw tool output and saved session context. Treat it like any other model request: do not capture a session whose contents you are not willing to send to the active provider. Kward rejects a source that does not fit the active model context instead of silently truncating it.
50
+
51
+ If the reviewed name already exists, Kward requires an explicit overwrite confirmation. Activate a saved draft later with `/skill <name>`.
52
+
53
+ Pan provides the same saved-session picker and editable review flow. RPC clients use `skills/captureSessions`, `skills/captureDraft`, and `skills/saveCapturedDraft`; see the [RPC protocol](rpc.md).
54
+
45
55
  ## Skill locations
46
56
 
47
57
  User-level skills are available in every project:
@@ -12,14 +12,14 @@ Kward normally chooses these tools itself. You do not need to know their exact n
12
12
 
13
13
  ## Guardrails
14
14
 
15
- Workspace tools use the active workspace as their boundary. File paths are workspace-relative by default, and file tools are guarded so Kward does not edit arbitrary unread files. Guardrails can be disabled with the `tools.workspace_guardrails` setting — see [Configuration](configuration.md). When disabled, file tools can access paths outside the workspace, but shell commands are unaffected since they already run as your OS user.
15
+ Workspace tools use the active workspace as their boundary. File paths are workspace-relative by default, and file tools are guarded so Kward does not edit arbitrary unread files. Guardrails can be disabled with the `tools.workspace_guardrails` setting — see [Configuration](configuration.md). When disabled, file tools can access paths outside the workspace. These guardrails do not affect shell commands; model-requested commands can instead use the separate, opt-in [command sandbox](sandboxing.md).
16
16
 
17
17
  Important behavior:
18
18
 
19
19
  - Existing files must be read in the current conversation before `write_file` or `edit_file` can change them.
20
20
  - Reads are bounded to avoid pulling very large files into context by accident. Files larger than 256 KB cannot be read or edited. Read output is capped at 50 KB or 2,000 lines, whichever comes first.
21
21
  - Edits use exact text replacement, so accidental partial or fuzzy changes fail instead of guessing.
22
- - Shell commands run as your operating-system user from the workspace. They are powerful and should be treated like commands you run yourself. Command output is capped at 128 KB.
22
+ - With sandboxing off (the default), shell commands run as your operating-system user from the workspace. Enable [command sandboxing](sandboxing.md) to apply an OS boundary to model-requested `run_shell_command` workers. Command output is capped at 128 KB.
23
23
 
24
24
  ## Reading the workspace
25
25
 
@@ -124,7 +124,7 @@ Arguments:
124
124
  - `command`: command to run.
125
125
  - `timeout_seconds`: optional timeout, default 30 seconds.
126
126
 
127
- Kward uses shell commands for tests, linters, build checks, and simple repository inspection. Command output is bounded at 128 KB and may be compacted before it is sent back into model context, while the original output remains available in the session record.
127
+ Kward uses shell commands for tests, linters, build checks, and simple repository inspection. When configured, the [command sandbox](sandboxing.md) applies to this tool and its descendants. Command output is bounded at 128 KB and may be compacted before it is sent back into model context, while the original output remains available in the session record.
128
128
 
129
129
  The output format is `Exit status: N` followed by `STDOUT:` and `STDERR:` sections. On timeout, Kward sends SIGTERM then SIGKILL after 0.2 seconds and returns a timeout error. Commands support cooperative cancellation when the session is cancelled.
130
130
 
data/kward.gemspec CHANGED
@@ -10,7 +10,7 @@ Gem::Specification.new do |spec|
10
10
  spec.description = "Kward is a Ruby CLI coding agent with local workspace tools, configurable prompts, web search, sessions, and an experimental JSON-RPC backend."
11
11
  spec.homepage = "https://github.com/kaiwood/kward"
12
12
  spec.license = "MIT"
13
- spec.required_ruby_version = ">= 3.2"
13
+ spec.required_ruby_version = ">= 3.4"
14
14
 
15
15
  spec.metadata["rubygems_mfa_required"] = "true"
16
16
  spec.metadata["source_code_uri"] = "https://github.com/kaiwood/kward"
@@ -25,6 +25,7 @@ module Kward
25
25
  doctor_directory_check("Session directory", SessionStore.new(cwd: current_workspace_root).session_dir, create: true),
26
26
  doctor_workspace_check,
27
27
  doctor_model_check,
28
+ doctor_local_endpoint_check(config),
28
29
  doctor_auth_check(config),
29
30
  doctor_pan_check(config_result),
30
31
  { status: :ok, label: "Color", message: @color_enabled ? "enabled" : "disabled" }
@@ -89,14 +90,34 @@ module Kward
89
90
  { status: :warning, label: "Model", message: e.message }
90
91
  end
91
92
 
93
+ def doctor_local_endpoint_check(config)
94
+ provider = ENV["KWARD_PROVIDER"].to_s.strip
95
+ provider = config["provider"].to_s.strip if provider.empty?
96
+ return { status: :ok, label: "Local endpoint", message: "not selected" } unless provider.casecmp?("local")
97
+
98
+ backend = ENV["KWARD_LOCAL_BACKEND"].to_s.strip
99
+ backend = config["local_backend"].to_s.strip if backend.empty?
100
+ defaults = Kward::Client::LOCAL_BASE_URLS
101
+ url = ENV["KWARD_LOCAL_BASE_URL"].to_s.strip
102
+ url = config["local_base_url"].to_s.strip if url.empty?
103
+ url = defaults.fetch(backend.empty? ? "ollama" : backend, defaults.fetch("ollama")) if url.empty?
104
+ uri = URI.parse(url)
105
+ loopback = ["127.0.0.1", "::1", "localhost"].include?(uri.host)
106
+ { status: loopback ? :ok : :warning, label: "Local endpoint", message: "#{url}#{loopback ? " (loopback)" : " (non-loopback)"}" }
107
+ rescue URI::InvalidURIError
108
+ { status: :error, label: "Local endpoint", message: "invalid URL: #{url}" }
109
+ end
110
+
92
111
  def doctor_auth_check(config)
93
112
  openai_auth = OpenAIOAuth.default_auth_path
94
113
  github_auth = GithubOAuth.default_auth_path
95
114
  has_openrouter = !config.to_h["openrouter_api_key"].to_s.empty? || !ENV["OPENROUTER_API_KEY"].to_s.empty?
115
+ local_provider = @client.respond_to?(:current_provider) && @client.current_provider == "Local"
96
116
  paths = []
97
117
  paths << "OpenAI OAuth" if File.exist?(openai_auth)
98
118
  paths << "GitHub OAuth" if File.exist?(github_auth)
99
119
  paths << "OpenRouter API key" if has_openrouter
120
+ paths << "Local endpoint (no authentication required)" if local_provider
100
121
  return { status: :ok, label: "Auth", message: paths.join(", ") } if paths.any?
101
122
 
102
123
  { status: :warning, label: "Auth", message: "no saved credentials found; run `kward login`" }
@@ -318,7 +318,7 @@ module Kward
318
318
  end
319
319
 
320
320
  def configured_workspace(root: current_workspace_root)
321
- Workspace.new(root: root, guardrails: workspace_guardrails_enabled?)
321
+ WorkspaceFactory.build(root: root, guardrails: workspace_guardrails_enabled?, config: safely_read_config.to_h)
322
322
  end
323
323
 
324
324
  def workspace_guardrails_enabled?
@@ -88,7 +88,7 @@ module Kward
88
88
  def configure_model_settings(conversation)
89
89
  initial_index = 0
90
90
  loop do
91
- selected, selected_index = select_settings_menu_item("Model & Reasoning", ["Provider", "Default model", "Reasoning effort", "Back"], initial_index)
91
+ selected, selected_index = select_settings_menu_item("Model & Reasoning", ["Provider", "Default model", "Local server preset", "Reasoning effort", "Back"], initial_index)
92
92
  break unless selected
93
93
 
94
94
  initial_index = selected_index if selected_index
@@ -97,6 +97,8 @@ module Kward
97
97
  configure_provider(conversation)
98
98
  when /\Adefault model/
99
99
  configure_model(conversation)
100
+ when /\Alocal server preset/
101
+ configure_local_server_preset(conversation)
100
102
  when /\Areasoning effort/
101
103
  configure_reasoning(conversation)
102
104
  else
@@ -118,7 +120,7 @@ module Kward
118
120
 
119
121
  def provider_choices
120
122
  current = current_model_provider
121
- ["Codex", "Anthropic", "OpenRouter", "Copilot"].map do |provider|
123
+ ["Codex", "Anthropic", "OpenRouter", "Copilot", "Local"].map do |provider|
122
124
  label = provider.dup
123
125
  label += " (current)" if provider == current
124
126
  label
@@ -131,10 +133,39 @@ module Kward
131
133
  return "Anthropic" if text.start_with?("anthropic") || text.start_with?("claude")
132
134
  return "OpenRouter" if text.start_with?("openrouter")
133
135
  return "Copilot" if text.start_with?("copilot")
136
+ return "Local" if text.start_with?("local")
134
137
 
135
138
  nil
136
139
  end
137
140
 
141
+ def configure_local_server_preset(conversation)
142
+ choices = [
143
+ "Ollama (http://127.0.0.1:11434/v1)",
144
+ "LM Studio (http://127.0.0.1:1234/v1)",
145
+ "llama.cpp (http://127.0.0.1:8080/v1)",
146
+ "Custom endpoint (edit config)",
147
+ "Back"
148
+ ]
149
+ selected = @prompt.select("Local server preset", choices, title: "Settings")
150
+ return unless selected
151
+
152
+ backend, url = case selected.to_s
153
+ when /\AOllama/ then ["ollama", "http://127.0.0.1:11434/v1"]
154
+ when /\ALM Studio/ then ["lm_studio", "http://127.0.0.1:1234/v1"]
155
+ when /\Allama\.cpp/ then ["llama_cpp", "http://127.0.0.1:8080/v1"]
156
+ when /\ACustom endpoint/
157
+ runtime_output("Set local_base_url in #{ConfigFiles.config_path}.")
158
+ return
159
+ else
160
+ return
161
+ end
162
+
163
+ ConfigFiles.update_config("local_backend" => backend, "local_base_url" => url)
164
+ reload_client_config
165
+ refresh_conversation_runtime(conversation)
166
+ @prompt.redraw if @prompt.respond_to?(:redraw)
167
+ end
168
+
138
169
  def configure_account_settings
139
170
  initial_index = 0
140
171
  loop do
@@ -524,6 +555,10 @@ module Kward
524
555
  case selected.to_s.downcase
525
556
  when /\Adefault persona/
526
557
  configure_default_persona(conversation)
558
+ when /\Aenable global principles/, /\Adisable global principles/
559
+ toggle_global_principles(conversation)
560
+ when /\Aglobal principles \(ignored/
561
+ runtime_output("Global principles are ignored while a replacement system prompt is configured.")
527
562
  when /\Aactive instructions/
528
563
  show_active_instructions_summary(conversation)
529
564
  else
@@ -533,13 +568,31 @@ module Kward
533
568
  end
534
569
 
535
570
  def personalization_setting_choices(conversation)
571
+ config = safely_read_config.to_h
572
+ replacement = ConfigFiles.system_prompt_file_path(config)
573
+ principles = if replacement
574
+ "Global principles (ignored by replacement)"
575
+ elsif ConfigFiles.include_config_principles?(config)
576
+ "Disable global principles"
577
+ else
578
+ "Enable global principles"
579
+ end
536
580
  [
537
581
  "Default persona (#{default_persona_label})",
582
+ principles,
538
583
  "Active instructions summary",
539
584
  "Back"
540
585
  ]
541
586
  end
542
587
 
588
+ def toggle_global_principles(conversation)
589
+ config = safely_read_config.to_h
590
+ settings = config["system_prompt"].is_a?(Hash) ? config["system_prompt"] : {}
591
+ ConfigFiles.update_config("system_prompt" => settings.merge("include_principles" => !ConfigFiles.include_config_principles?(config)))
592
+ conversation&.refresh_system_message!
593
+ @prompt.redraw if @prompt.respond_to?(:redraw)
594
+ end
595
+
543
596
  def default_persona_label
544
597
  personas = safely_read_config.to_h["personas"]
545
598
  value = personas.is_a?(Hash) ? personas["default"] : nil
@@ -569,9 +622,18 @@ module Kward
569
622
 
570
623
  def show_active_instructions_summary(conversation)
571
624
  label = ConfigFiles.active_persona_label(workspace_root: current_workspace_root, model: current_model_id, config: safely_read_config.to_h)
625
+ config = safely_read_config.to_h
626
+ replacement_path = ConfigFiles.system_prompt_file_path(config)
572
627
  lines = ["Active persona: #{label || "none"}"]
573
- lines << "Global PRINCIPLES.md: #{ConfigFiles.agents_prompt ? "present" : "absent"}"
574
- lines << "Workspace AGENTS.md: #{ConfigFiles.workspace_agents_prompt(current_workspace_root) ? "present" : "absent"}"
628
+ if replacement_path
629
+ lines << "System prompt: replacement (#{replacement_path})"
630
+ lines << "Global PRINCIPLES.md: ignored by replacement"
631
+ lines << "Workspace AGENTS.md: ignored by replacement"
632
+ else
633
+ lines << "System prompt: Kward default"
634
+ lines << "Global PRINCIPLES.md: #{ConfigFiles.include_config_principles?(config) ? (ConfigFiles.agents_prompt ? "present" : "absent") : "disabled"}"
635
+ lines << "Workspace AGENTS.md: #{ConfigFiles.workspace_agents_prompt(current_workspace_root) ? "present" : "absent"}"
636
+ end
575
637
  lines << "Messages: #{conversation.messages.length}" if conversation&.respond_to?(:messages)
576
638
  runtime_output(lines.join("\n"))
577
639
  end
@@ -15,6 +15,9 @@ module Kward
15
15
  when "stats"
16
16
  run_busy_local_command_and_requeue { print_stats(argument) }
17
17
  [true, nil]
18
+ when "sandbox"
19
+ run_busy_local_command_and_requeue { handle_sandbox_command(argument, interactive_workspace_root(agent)) }
20
+ [true, nil]
18
21
  when "memory"
19
22
  activity = memory_summarize_command?(argument) ? "summarizing" : "loading"
20
23
  run_busy_local_command_and_requeue(activity: activity) { handle_memory_command(argument, agent) }
@@ -23,7 +26,11 @@ module Kward
23
26
  run_busy_local_command_and_requeue { handle_hooks_command(argument) }
24
27
  [true, nil]
25
28
  when "skill"
26
- run_busy_local_command_and_requeue { activate_skill_command(argument, agent) }
29
+ if argument.to_s.strip == "capture"
30
+ capture_skill_from_session(session_store)
31
+ else
32
+ run_busy_local_command_and_requeue { activate_skill_command(argument, agent) }
33
+ end
27
34
  [true, nil]
28
35
  when "redraw"
29
36
  run_busy_local_command_and_requeue { @prompt.redraw if @prompt.respond_to?(:redraw) }
@@ -114,6 +121,48 @@ module Kward
114
121
  PromptCommands.parse(command) || [nil, ""]
115
122
  end
116
123
 
124
+ def handle_sandbox_command(argument, workspace_root)
125
+ action, value = argument.to_s.strip.split(/\s+/, 2)
126
+ case action
127
+ when nil, "", "status"
128
+ print_sandbox_status(workspace_root)
129
+ when *Sandbox::Policy::MODES
130
+ ConfigFiles.update_nested_config("sandbox", { "mode" => action })
131
+ runtime_output("Command sandbox mode set to #{action}. New sessions and tabs use the updated policy.")
132
+ when "network"
133
+ network = value.to_s
134
+ unless Sandbox::Policy::NETWORK_MODES.include?(network)
135
+ runtime_output("Usage: /sandbox network allow|deny")
136
+ return
137
+ end
138
+
139
+ ConfigFiles.update_nested_config("sandbox", { "network" => network })
140
+ runtime_output("Command sandbox child network set to #{network}. New sessions and tabs use the updated policy.")
141
+ else
142
+ runtime_output("Usage: /sandbox [status|off|read_only|workspace_write|network allow|network deny]")
143
+ end
144
+ rescue ArgumentError => error
145
+ runtime_output("Sandbox configuration error: #{error.message}")
146
+ end
147
+
148
+ def print_sandbox_status(workspace_root)
149
+ config = ConfigFiles.read_config
150
+ policy = ConfigFiles.sandbox_policy(workspace_root, config)
151
+ runner = Sandbox::RunnerFactory.build(policy)
152
+ capabilities = runner.capabilities
153
+ lines = [
154
+ "Command sandbox",
155
+ "Mode: #{policy.mode}",
156
+ "Child network: #{policy.network}",
157
+ "Backend: #{capabilities.backend}",
158
+ "Filesystem enforcement: #{capabilities.filesystem_enforced? ? "active" : "inactive"}",
159
+ "Child-network enforcement: #{capabilities.child_network_enforced? ? "active" : "inactive"}",
160
+ "Scope: model-requested run_shell_command workers only"
161
+ ]
162
+ lines << "Reason: #{capabilities.reason}" unless capabilities.reason.to_s.empty?
163
+ runtime_output(lines.join("\n"))
164
+ end
165
+
117
166
  def open_or_resume_session(session_store, argument)
118
167
  unless session_store
119
168
  say_sessions_unavailable
@@ -144,6 +193,65 @@ module Kward
144
193
  end
145
194
  end
146
195
 
196
+ def capture_skill_from_session(session_store)
197
+ unless session_store
198
+ say_sessions_unavailable
199
+ return
200
+ end
201
+ unless @prompt.respond_to?(:review_document)
202
+ runtime_output("Skill capture is available only in the interactive terminal prompt.")
203
+ return
204
+ end
205
+
206
+ source_path = select_skill_capture_session(session_store)
207
+ return unless source_path
208
+
209
+ capture = Skills::Capture.new(session_store: session_store, client: @client)
210
+ draft = run_busy_local_command_and_requeue(activity: "capturing skill") { capture.generate(source_path) }
211
+ review_captured_skill(capture, draft)
212
+ rescue Skills::Capture::Error => error
213
+ runtime_output("Skill capture failed: #{error.message}")
214
+ end
215
+
216
+ def select_skill_capture_session(session_store)
217
+ sessions = session_store.capture_candidates
218
+ if sessions.empty?
219
+ runtime_output("No saved sessions found.")
220
+ return nil
221
+ end
222
+
223
+ labels = session_picker_labels(sessions)
224
+ if @prompt.respond_to?(:select)
225
+ choice = @prompt.select("Capture session>", labels, title: "Capture skill from session")
226
+ return sessions[labels.index(choice)]&.path
227
+ end
228
+
229
+ runtime_output((["Saved sessions:"] + labels.each_with_index.map { |label, index| "#{index + 1}. #{label}" }).join("\n"))
230
+ answer = @prompt.ask("Session number or path>").to_s.strip
231
+ answer.match?(/\A\d+\z/) ? sessions[answer.to_i - 1]&.path : answer
232
+ end
233
+
234
+ def review_captured_skill(capture, draft)
235
+ overwrite = false
236
+ saved = nil
237
+ @prompt.review_document(title: "Review captured skill", content: draft.content) do |content|
238
+ begin
239
+ saved = capture.save(content, overwrite: overwrite)
240
+ nil
241
+ rescue Skills::Capture::ConflictError
242
+ if overwrite
243
+ "Could not overwrite the existing skill. Review the name and try again."
244
+ else
245
+ overwrite = true
246
+ "A personal skill with this name exists. Press Ctrl+S again to overwrite it."
247
+ end
248
+ rescue Skills::Capture::Error => error
249
+ error.message
250
+ end
251
+ end
252
+ runtime_output("Saved personal skill: #{capture.skill_path(saved.name)}") if saved
253
+ end
254
+
147
255
  def activate_skill_command(name, agent)
148
256
  skill_name = name.to_s.strip
149
257
  if skill_name.empty?
data/lib/kward/cli.rb CHANGED
@@ -38,13 +38,14 @@ require_relative "tab_store"
38
38
  require_relative "tab_driver"
39
39
  require_relative "session_trash"
40
40
  require_relative "session_tree_renderer"
41
+ require_relative "skills/capture"
41
42
  require_relative "starter_pack_installer"
42
43
  require_relative "steering"
43
44
  require_relative "update_check"
44
45
  require_relative "tools/tool_call"
45
46
  require_relative "tools/registry"
46
47
  require_relative "telemetry/stats"
47
- require_relative "workspace"
48
+ require_relative "workspace_factory"
48
49
  require_relative "cli/commands"
49
50
  require_relative "cli/auth_commands"
50
51
  require_relative "cli/doctor"
@@ -6,6 +6,7 @@ require_relative "frontmatter"
6
6
  require_relative "private_file"
7
7
  require_relative "path_guard"
8
8
  require_relative "permissions/policy"
9
+ require_relative "sandbox/policy"
9
10
  require_relative "ekwsh"
10
11
  require_relative "editor_mode"
11
12
  require_relative "diff_view_mode"
@@ -151,6 +152,9 @@ module Kward
151
152
  "trust_project" => false
152
153
  },
153
154
  "enforce_workspace_agents_file" => false,
155
+ "system_prompt" => {
156
+ "include_principles" => true
157
+ },
154
158
  "mcpServers" => {},
155
159
  "tools" => {
156
160
  "workspace_guardrails" => true
@@ -158,6 +162,12 @@ module Kward
158
162
  "permissions" => {
159
163
  "enabled" => false,
160
164
  "mode" => "ask"
165
+ },
166
+ "sandbox" => {
167
+ "mode" => "off",
168
+ "network" => "deny",
169
+ "writable_roots" => [],
170
+ "protect_git_metadata" => true
161
171
  }
162
172
  }
163
173
  end
@@ -498,6 +508,18 @@ module Kward
498
508
  Permissions::Policy.from_config(config)
499
509
  end
500
510
 
511
+ # Builds the user-controlled command sandbox policy for a workspace.
512
+ def sandbox_policy(workspace_root, config = read_config)
513
+ sandbox = config["sandbox"].is_a?(Hash) ? config["sandbox"] : {}
514
+ Sandbox::Policy.new(
515
+ mode: sandbox.fetch("mode", "off"),
516
+ network: sandbox.fetch("network", "deny"),
517
+ workspace_root: workspace_root,
518
+ writable_roots: sandbox.fetch("writable_roots", []),
519
+ protect_git_metadata: sandbox.fetch("protect_git_metadata", true)
520
+ )
521
+ end
522
+
501
523
  # Returns whether project-level Agent Skills should be loaded from the workspace.
502
524
  def project_skills_trusted?(config = read_config)
503
525
  skills = config["skills"].is_a?(Hash) ? config["skills"] : {}
@@ -558,13 +580,61 @@ module Kward
558
580
  # alias for existing installations.
559
581
  #
560
582
  # @return [String, nil] prompt text, or nil when absent/too large
561
- def agents_prompt
583
+ def agents_prompt(config: read_config)
584
+ return nil unless include_config_principles?(config)
585
+
562
586
  path = config_principles_path
563
587
  return read_prompt_file(path, "Kward principles file") if File.exist?(path)
564
588
 
565
589
  read_prompt_file(config_agents_path, "Kward AGENTS.md alias")
566
590
  end
567
591
 
592
+ # Returns whether config-directory principles are included in normal system
593
+ # prompt assembly. Replacement prompt files bypass all assembled sections.
594
+ def include_config_principles?(config = read_config)
595
+ settings = config["system_prompt"]
596
+ return true unless settings.is_a?(Hash)
597
+
598
+ settings["include_principles"] != false
599
+ end
600
+
601
+ # Returns the configured replacement system prompt path, or nil when normal
602
+ # prompt assembly should be used. Relative paths are anchored to the config
603
+ # directory so configuration remains portable with KWARD_CONFIG_PATH.
604
+ def system_prompt_file_path(config = read_config)
605
+ settings = config["system_prompt"]
606
+ value = settings.is_a?(Hash) ? settings["file"].to_s.strip : ""
607
+ return nil if value.empty?
608
+
609
+ File.expand_path(value, config_dir)
610
+ end
611
+
612
+ # Returns the replacement system prompt text when configured. A configured
613
+ # but unavailable file intentionally yields nil: callers must not fall back
614
+ # to Kward's larger assembled prompt in replacement mode.
615
+ def system_prompt_file(config = read_config)
616
+ path = system_prompt_file_path(config)
617
+ return nil unless path
618
+
619
+ read_prompt_file(path, "custom system prompt file")
620
+ end
621
+
622
+ def replacement_system_prompt?(config = read_config)
623
+ !system_prompt_file_path(config).nil?
624
+ end
625
+
626
+ # Returns a lightweight fingerprint for config-owned prompt sources. It is
627
+ # used by active conversations to pick up prompt edits without a restart.
628
+ def system_prompt_sources_fingerprint
629
+ config = read_config
630
+ paths = [config_path, system_prompt_file_path(config)]
631
+ if include_config_principles?(config)
632
+ paths << (File.exist?(config_principles_path) ? config_principles_path : config_agents_path)
633
+ end
634
+
635
+ paths.compact.map { |path| prompt_source_signature(path) }.join("\0")
636
+ end
637
+
568
638
  def config_principles_path
569
639
  File.join(config_dir, "PRINCIPLES.md")
570
640
  end
@@ -686,6 +756,15 @@ module Kward
686
756
  read_prompt_file(workspace_agents_path(workspace_root), "workspace AGENTS.md")
687
757
  end
688
758
 
759
+ def prompt_source_signature(path)
760
+ return "#{path}:missing" unless File.exist?(path)
761
+
762
+ stat = File.stat(path)
763
+ "#{path}:#{stat.size}:#{stat.mtime.to_f}"
764
+ rescue StandardError
765
+ "#{path}:unavailable"
766
+ end
767
+
689
768
  def read_prompt_file(path, label)
690
769
  return nil unless File.exist?(path)
691
770
 
@@ -87,6 +87,7 @@ module Kward
87
87
  end
88
88
  @compaction_system_message = compaction_system_message
89
89
  @workspace_agents_mtime = workspace_agents_mtime
90
+ @system_prompt_sources_fingerprint = ConfigFiles.system_prompt_sources_fingerprint
90
91
  @last_entry_compaction = false
91
92
  @memory_context = memory_context
92
93
  @session_memories = Array(session_memories)
@@ -191,6 +192,7 @@ module Kward
191
192
  @on_system_message_change&.call(replacement)
192
193
  @compaction_system_message = Prompts.system_message(workspace_root: @workspace_root, include_workspace_personality: false, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time)
193
194
  @workspace_agents_mtime = workspace_agents_mtime
195
+ @system_prompt_sources_fingerprint = ConfigFiles.system_prompt_sources_fingerprint
194
196
  replacement
195
197
  end
196
198
 
@@ -206,7 +208,18 @@ module Kward
206
208
  end
207
209
 
208
210
  def refresh_system_message_if_workspace_agents_changed!
209
- refresh_system_message! if @system_message_enabled && workspace_agents_mtime != @workspace_agents_mtime
211
+ refresh_system_message_if_sources_changed!
212
+ end
213
+
214
+ # Refreshes runtime prompt state after workspace or config-owned prompt
215
+ # sources change. Keep the older public method above as a compatibility
216
+ # alias for existing callers.
217
+ def refresh_system_message_if_sources_changed!
218
+ return unless @system_message_enabled
219
+
220
+ changed = workspace_agents_mtime != @workspace_agents_mtime ||
221
+ ConfigFiles.system_prompt_sources_fingerprint != @system_prompt_sources_fingerprint
222
+ refresh_system_message! if changed
210
223
  end
211
224
 
212
225
  def mark_read(path)
@@ -18,7 +18,7 @@ module Kward
18
18
  @terminate_on_output_limit = terminate_on_output_limit
19
19
  end
20
20
 
21
- def run(*command, env: {}, cwd: Dir.pwd, cancellation: nil, &block)
21
+ def run(*command, env: {}, cwd: Dir.pwd, cancellation: nil, unsetenv_others: false, &block)
22
22
  cancellation&.raise_if_cancelled!
23
23
  stdout_buffer = +""
24
24
  stderr_buffer = +""
@@ -27,7 +27,7 @@ module Kward
27
27
  timed_out = false
28
28
  queue = Queue.new
29
29
 
30
- Open3.popen3(env.to_h, *command, chdir: cwd.to_s, pgroup: true) do |stdin, stdout, stderr, wait_thread|
30
+ Open3.popen3(env.to_h, *command, chdir: cwd.to_s, pgroup: true, unsetenv_others: unsetenv_others) do |stdin, stdout, stderr, wait_thread|
31
31
  stdin.close
32
32
  readers = [
33
33
  read_stream(stdout, :stdout, queue),