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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +7 -2
- data/.github/workflows/pages.yml +1 -1
- data/CHANGELOG.md +24 -0
- data/Gemfile.lock +2 -2
- data/README.md +4 -2
- data/doc/agent-tools.md +1 -1
- data/doc/configuration.md +68 -2
- data/doc/extensibility.md +2 -0
- data/doc/getting-started.md +1 -1
- data/doc/local-models.md +130 -0
- data/doc/permissions.md +3 -2
- data/doc/releasing.md +1 -1
- data/doc/rpc.md +12 -1
- data/doc/sandboxing.md +120 -0
- data/doc/security.md +11 -5
- data/doc/skills.md +10 -0
- data/doc/workspace-tools.md +3 -3
- data/kward.gemspec +1 -1
- data/lib/kward/cli/doctor.rb +21 -0
- data/lib/kward/cli/runtime_helpers.rb +1 -1
- data/lib/kward/cli/settings.rb +66 -4
- data/lib/kward/cli/slash_commands.rb +109 -1
- data/lib/kward/cli.rb +2 -1
- data/lib/kward/config_files.rb +80 -1
- data/lib/kward/conversation.rb +14 -1
- data/lib/kward/local_command_runner.rb +2 -2
- data/lib/kward/model/client.rb +140 -4
- data/lib/kward/model/model_info.rb +11 -1
- data/lib/kward/model/payloads.rb +1 -0
- data/lib/kward/model/stream_parser.rb +58 -26
- data/lib/kward/pan/index.html.erb +50 -0
- data/lib/kward/pan/server.rb +49 -2
- data/lib/kward/plugin_registry.rb +1 -1
- data/lib/kward/prompt_interface/editor/controller.rb +36 -3
- data/lib/kward/prompt_interface.rb +18 -0
- data/lib/kward/prompts/commands.rb +2 -0
- data/lib/kward/prompts.rb +16 -5
- data/lib/kward/rpc/plugin_chat_manager.rb +4 -1
- data/lib/kward/rpc/server.rb +42 -4
- data/lib/kward/rpc/session_manager.rb +42 -8
- data/lib/kward/sandbox/capabilities.rb +39 -0
- data/lib/kward/sandbox/command_runner.rb +28 -0
- data/lib/kward/sandbox/environment.rb +24 -0
- data/lib/kward/sandbox/linux_bubblewrap_runner.rb +71 -0
- data/lib/kward/sandbox/macos_seatbelt_runner.rb +96 -0
- data/lib/kward/sandbox/passthrough_runner.rb +13 -0
- data/lib/kward/sandbox/policy.rb +74 -0
- data/lib/kward/sandbox/runner_factory.rb +55 -0
- data/lib/kward/sandbox/unavailable_runner.rb +21 -0
- data/lib/kward/sandbox.rb +9 -0
- data/lib/kward/session_store.rb +26 -0
- data/lib/kward/skills/capture.rb +144 -0
- data/lib/kward/tools/registry.rb +19 -4
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workspace.rb +18 -3
- data/lib/kward/workspace_factory.rb +17 -0
- data/templates/default/fulldoc/html/js/kward.js +1 -0
- data/templates/default/kward_navigation.rb +4 -2
- data/templates/default/layout/html/setup.rb +1 -0
- 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
|
-
|
|
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
|
|
127
|
-
"
|
|
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
|
|
data/lib/kward/rpc/server.rb
CHANGED
|
@@ -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 "../
|
|
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(
|
|
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
|
-
|
|
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 "../
|
|
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
|
-
|
|
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
|