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
@@ -0,0 +1,74 @@
1
+ require "pathname"
2
+
3
+ # Namespace for operating-system command sandboxing.
4
+ module Kward
5
+ module Sandbox
6
+ # Immutable, user-configured restrictions for a command worker.
7
+ class Policy
8
+ MODES = %w[off read_only workspace_write].freeze
9
+ NETWORK_MODES = %w[deny allow].freeze
10
+
11
+ attr_reader :mode, :network, :workspace_root, :writable_roots
12
+
13
+ def initialize(mode: "off", network: "deny", workspace_root:, writable_roots: [], protect_git_metadata: true)
14
+ @mode = normalize_mode(mode)
15
+ @network = normalize_network(network)
16
+ @workspace_root = canonical_path(workspace_root)
17
+ @writable_roots = normalize_writable_roots(writable_roots)
18
+ @protect_git_metadata = protect_git_metadata != false
19
+ end
20
+
21
+ def enabled?
22
+ mode != "off"
23
+ end
24
+
25
+ def read_only?
26
+ mode == "read_only"
27
+ end
28
+
29
+ def workspace_write?
30
+ mode == "workspace_write"
31
+ end
32
+
33
+ def protect_git_metadata?
34
+ @protect_git_metadata
35
+ end
36
+
37
+ def child_network_allowed?
38
+ network == "allow"
39
+ end
40
+
41
+ def command_writable_roots
42
+ return [] unless workspace_write?
43
+
44
+ ([workspace_root] + writable_roots).uniq
45
+ end
46
+
47
+ private
48
+
49
+ def normalize_mode(value)
50
+ mode = value.to_s
51
+ return mode if MODES.include?(mode)
52
+
53
+ raise ArgumentError, "sandbox mode must be one of: #{MODES.join(", ")}"
54
+ end
55
+
56
+ def normalize_network(value)
57
+ network = value.to_s
58
+ return network if NETWORK_MODES.include?(network)
59
+
60
+ raise ArgumentError, "sandbox network must be one of: #{NETWORK_MODES.join(", ")}"
61
+ end
62
+
63
+ def normalize_writable_roots(roots)
64
+ Array(roots).map { |root| canonical_path(root) }.uniq.reject { |root| root == workspace_root }
65
+ end
66
+
67
+ def canonical_path(path)
68
+ Pathname.new(path.to_s).realpath.to_s
69
+ rescue Errno::ENOENT
70
+ raise ArgumentError, "sandbox path does not exist: #{path}"
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,55 @@
1
+ require_relative "capabilities"
2
+ require_relative "passthrough_runner"
3
+ require_relative "unavailable_runner"
4
+ require_relative "macos_seatbelt_runner"
5
+ require_relative "linux_bubblewrap_runner"
6
+
7
+ # Namespace for operating-system command sandboxing.
8
+ module Kward
9
+ module Sandbox
10
+ # Selects the command runner that can enforce a policy on the current host.
11
+ module RunnerFactory
12
+ module_function
13
+
14
+ def build(policy, platform: RUBY_PLATFORM)
15
+ return PassthroughRunner.new(policy:, capabilities: off_capabilities) unless policy.enabled?
16
+
17
+ runner_class = runner_class_for(platform)
18
+ return UnavailableRunner.new(policy:, capabilities: unsupported_capabilities(platform)) unless runner_class
19
+
20
+ capabilities = runner_class.capabilities(platform:)
21
+ return UnavailableRunner.new(policy:, capabilities:) unless capabilities.available?
22
+
23
+ runner_class.new(policy:, capabilities:)
24
+ end
25
+
26
+ def off_capabilities
27
+ Capabilities.new(
28
+ available: true,
29
+ filesystem_enforced: false,
30
+ child_network_enforced: false,
31
+ backend: "off"
32
+ )
33
+ end
34
+
35
+ def unsupported_capabilities(platform)
36
+ Capabilities.new(
37
+ available: false,
38
+ filesystem_enforced: false,
39
+ child_network_enforced: false,
40
+ backend: "unsupported",
41
+ reason: "Sandboxing is unsupported on #{platform}"
42
+ )
43
+ end
44
+
45
+ def runner_class_for(platform)
46
+ value = platform.to_s
47
+ return MacOSSeatbeltRunner if value.include?("darwin")
48
+ return LinuxBubblewrapRunner if value.include?("linux")
49
+
50
+ nil
51
+ end
52
+ private_class_method :runner_class_for
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,21 @@
1
+ require_relative "command_runner"
2
+
3
+ # Namespace for operating-system command sandboxing.
4
+ module Kward
5
+ module Sandbox
6
+ # Raised when a requested sandbox cannot be enforced on this host.
7
+ class UnavailableError < StandardError
8
+ end
9
+
10
+ # Fails closed rather than running a requested sandbox policy unrestricted.
11
+ class UnavailableRunner < CommandRunner
12
+ def command_argv(command, cwd:)
13
+ []
14
+ end
15
+
16
+ def run(command, cwd:, timeout_seconds:, max_output_bytes:, cancellation: nil, &block)
17
+ raise UnavailableError, "Sandbox #{policy.mode} is unavailable: #{capabilities.reason}"
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,9 @@
1
+ require_relative "sandbox/policy"
2
+ require_relative "sandbox/capabilities"
3
+ require_relative "sandbox/environment"
4
+ require_relative "sandbox/command_runner"
5
+ require_relative "sandbox/passthrough_runner"
6
+ require_relative "sandbox/unavailable_runner"
7
+ require_relative "sandbox/macos_seatbelt_runner"
8
+ require_relative "sandbox/linux_bubblewrap_runner"
9
+ require_relative "sandbox/runner_factory"
@@ -315,6 +315,16 @@ module Kward
315
315
  recent_sessions(limit: limit, keep_empty_path: keep_empty_path)
316
316
  end
317
317
 
318
+ # Lists all persisted sessions under this config directory for an explicit
319
+ # user-selected operation such as skill capture. Unlike #recent, this is not
320
+ # scoped to the store's current workspace.
321
+ def capture_candidates
322
+ pattern = File.join(@config_dir, "sessions", "**", "*.jsonl")
323
+ Dir.glob(pattern).filter_map { |path| session_info(path) }.sort_by(&:modified_at).reverse
324
+ rescue StandardError
325
+ []
326
+ end
327
+
318
328
  # Persists the last active session pointer for workspace auto-resume.
319
329
  def remember_last_session(session)
320
330
  return unless session&.path
@@ -430,6 +440,22 @@ module Kward
430
440
  current_leaf_id(records_from_file(resolve_session_path(path)))
431
441
  end
432
442
 
443
+ # Returns the complete persisted active branch for an explicit export-like
444
+ # consumer. Unlike #load, this does not attach a conversation or mutate the
445
+ # session file.
446
+ #
447
+ # @return [Hash] session metadata, saved system-prompt snapshots, and active branch records
448
+ def capture_branch(path)
449
+ resolved_path = resolve_session_path(path)
450
+ records = records_from_file(resolved_path)
451
+ {
452
+ path: resolved_path,
453
+ header: session_header(records, resolved_path),
454
+ system_prompts: records.select { |record| record["type"] == "system_prompt" },
455
+ entries: branch_records(records)
456
+ }
457
+ end
458
+
433
459
  def append_record(path, record)
434
460
  File.open(path, "a", 0o600) do |file|
435
461
  file.write(JSON.generate(record))
@@ -0,0 +1,144 @@
1
+ require "fileutils"
2
+ require "json"
3
+ require "tempfile"
4
+ require "yaml"
5
+ require_relative "../compaction/token_estimator"
6
+ require_relative "../config_files"
7
+ require_relative "../message_access"
8
+
9
+ # Namespace for the Kward CLI agent runtime.
10
+ module Kward
11
+ module Skills
12
+ # Generates and persists reviewed personal skills from saved session branches.
13
+ class Capture
14
+ Draft = Struct.new(:content, :source_path, :name, :description, keyword_init: true)
15
+
16
+ class Error < StandardError; end
17
+ class SourceTooLargeError < Error; end
18
+ class InvalidDraftError < Error; end
19
+ class ConflictError < Error; end
20
+
21
+ OUTPUT_TOKEN_RESERVE = 4_096
22
+ PROMPT_TOKEN_RESERVE = 1_024
23
+ NAME_PATTERN = /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/
24
+
25
+ def initialize(session_store:, client:, config_dir: ConfigFiles.config_dir, token_estimator: Compaction::TokenEstimator.new)
26
+ @session_store = session_store
27
+ @client = client
28
+ @config_dir = config_dir
29
+ @token_estimator = token_estimator
30
+ end
31
+
32
+ def generate(session_path, cancellation: nil)
33
+ ensure_personal_session_path!(session_path)
34
+ source = @session_store.capture_branch(session_path)
35
+ raise Error, "Session has no active branch to capture" if source[:entries].empty?
36
+
37
+ source_json = JSON.pretty_generate(source)
38
+ ensure_source_fits!(source_json)
39
+ response = @client.chat(draft_messages(source_json), tools: [], cancellation: cancellation)
40
+ draft(source_path: source[:path], content: MessageAccess.content(response).to_s)
41
+ end
42
+
43
+ def save(content, overwrite: false)
44
+ reviewed_draft = draft(content: content)
45
+ path = skill_path(reviewed_draft.name)
46
+ if File.exist?(path) && !overwrite
47
+ raise ConflictError, "A personal skill named #{reviewed_draft.name.inspect} already exists"
48
+ end
49
+
50
+ FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
51
+ write_atomically(path, reviewed_draft.content)
52
+ reviewed_draft
53
+ end
54
+
55
+ def skill_path(name)
56
+ File.join(@config_dir, "skills", name.to_s, "SKILL.md")
57
+ end
58
+
59
+ private
60
+
61
+ def ensure_personal_session_path!(session_path)
62
+ sessions_root = File.realpath(File.join(@config_dir, "sessions"))
63
+ source_path = File.realpath(session_path)
64
+ return if source_path.start_with?("#{sessions_root}#{File::SEPARATOR}")
65
+
66
+ raise Error, "Skill capture source must be a persisted Kward session"
67
+ rescue Errno::ENOENT
68
+ raise Error, "Skill capture source must be a persisted Kward session"
69
+ end
70
+
71
+ def ensure_source_fits!(source_json)
72
+ context_window = @client.current_context_window
73
+ return unless context_window
74
+
75
+ available_tokens = context_window.to_i - OUTPUT_TOKEN_RESERVE - PROMPT_TOKEN_RESERVE
76
+ source_tokens = @token_estimator.estimate_tokens(source_json)
77
+ return if source_tokens <= available_tokens
78
+
79
+ raise SourceTooLargeError,
80
+ "Selected session needs about #{source_tokens} tokens, but only #{[available_tokens, 0].max} are available for capture"
81
+ end
82
+
83
+ def draft_messages(source_json)
84
+ [
85
+ {
86
+ role: "system",
87
+ content: <<~INSTRUCTIONS
88
+ Create one reusable Kward Agent Skill from the saved session supplied by the user.
89
+ Return only a complete SKILL.md document. Include YAML frontmatter with a lowercase
90
+ hyphenated `name` and a concise `description`, followed by practical reusable
91
+ instructions. Derive guidance from demonstrated workflow, commands, and conventions;
92
+ omit credentials, private values, and project-specific incidental details. Do not
93
+ claim the skill grants permissions or tool access.
94
+ INSTRUCTIONS
95
+ },
96
+ {
97
+ role: "user",
98
+ content: "Saved session active branch (complete persisted records):\n\n#{source_json}"
99
+ }
100
+ ]
101
+ end
102
+
103
+ def draft(source_path: nil, content:)
104
+ metadata, body = parse_skill(content)
105
+ name = metadata.fetch("name", "").to_s.strip
106
+ description = metadata.fetch("description", "").to_s.strip
107
+ raise InvalidDraftError, "Skill frontmatter must include a name" if name.empty?
108
+ raise InvalidDraftError, "Skill name must use lowercase letters, digits, and hyphens" unless NAME_PATTERN.match?(name)
109
+ raise InvalidDraftError, "Skill frontmatter must include a description" if description.empty?
110
+ raise InvalidDraftError, "Skill description exceeds 1024 characters" if description.length > 1024
111
+ raise InvalidDraftError, "Skill instructions must not be empty" if body.strip.empty?
112
+
113
+ Draft.new(content: normalize_content(content), source_path: source_path, name: name, description: description)
114
+ end
115
+
116
+ def parse_skill(content)
117
+ match = content.to_s.match(/\A---\r?\n(.*?)\r?\n---\r?\n(.*)\z/m)
118
+ raise InvalidDraftError, "Skill must begin with YAML frontmatter" unless match
119
+
120
+ metadata = YAML.safe_load(match[1], permitted_classes: [], aliases: false)
121
+ raise InvalidDraftError, "Skill frontmatter must be a mapping" unless metadata.is_a?(Hash)
122
+
123
+ [metadata.transform_keys(&:to_s), match[2]]
124
+ rescue Psych::SyntaxError => error
125
+ raise InvalidDraftError, "Invalid skill frontmatter: #{error.message}"
126
+ end
127
+
128
+ def normalize_content(content)
129
+ "#{content.to_s.rstrip}\n"
130
+ end
131
+
132
+ def write_atomically(path, content)
133
+ Tempfile.create(["SKILL", ".tmp"], File.dirname(path)) do |file|
134
+ file.write(content)
135
+ file.flush
136
+ file.fsync
137
+ File.chmod(0o600, file.path)
138
+ File.rename(file.path, path)
139
+ end
140
+ File.chmod(0o600, path)
141
+ end
142
+ end
143
+ end
144
+ end
@@ -49,6 +49,19 @@ module Kward
49
49
  # explicit tool errors.
50
50
  # @api public
51
51
  class ToolRegistry
52
+ CORE_TOOL_NAMES = %w[
53
+ list_directory
54
+ read_file
55
+ write_file
56
+ edit_file
57
+ run_shell_command
58
+ code_search
59
+ summarize_file_structure
60
+ context_for_task
61
+ context_budget_stats
62
+ retrieve_tool_output
63
+ ].freeze
64
+
52
65
  # Tool schemas advertised to the model for the current frontend and config.
53
66
  #
54
67
  # @return [Array<Hash>] tool schemas currently advertised to the model
@@ -462,13 +475,15 @@ module Kward
462
475
  def build_tools
463
476
  tools = all_tools
464
477
  tools = tools.select { |tool| @allowed_tool_names.include?(tool.name) } if @allowed_tool_names
465
- tools.to_h { |tool| [tool.name, tool] }
478
+ tools.each_with_object({}) do |tool, result|
479
+ raise ArgumentError, "Duplicate tool name: #{tool.name}" if result.key?(tool.name)
480
+
481
+ result[tool.name] = tool
482
+ end
466
483
  end
467
484
 
468
485
  def build_schema_tools
469
- tools = @tools.values_at(
470
- "list_directory", "read_file", "write_file", "edit_file", "run_shell_command", "code_search", "summarize_file_structure", "context_for_task", "context_budget_stats", "retrieve_tool_output"
471
- )
486
+ tools = @tools.values_at(*CORE_TOOL_NAMES)
472
487
  tools.concat(@tools.values_at("web_search", "fetch_content", "fetch_raw")) if web_search_available?
473
488
  tools.concat(@tools.values.select { |tool| tool.is_a?(Tools::MCPTool) })
474
489
  tools << @tools["read_skill"] if skills_available?
data/lib/kward/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # Namespace for the Kward CLI agent runtime.
2
2
  module Kward
3
3
  # Current gem version.
4
- VERSION = "0.78.0"
4
+ VERSION = "0.79.0"
5
5
  end
@@ -1,5 +1,6 @@
1
1
  require "pathname"
2
2
  require_relative "local_command_runner"
3
+ require_relative "sandbox"
3
4
  require_relative "session_diff"
4
5
 
5
6
  require_relative "path_guard"
@@ -27,13 +28,14 @@ module Kward
27
28
  EXPECTED_FILE_ERRORS = [SecurityError, Errno::ENOENT, Errno::EACCES, Errno::EPERM, Errno::EISDIR, Errno::ENOTDIR].freeze
28
29
 
29
30
  # Creates an object for workspace filesystem and shell operations.
30
- def initialize(root: Dir.pwd, max_file_bytes: MAX_FILE_BYTES, max_read_output_bytes: MAX_READ_OUTPUT_BYTES, max_read_output_lines: MAX_READ_OUTPUT_LINES, max_command_output_bytes: MAX_COMMAND_OUTPUT_BYTES, guardrails: true)
31
+ def initialize(root: Dir.pwd, max_file_bytes: MAX_FILE_BYTES, max_read_output_bytes: MAX_READ_OUTPUT_BYTES, max_read_output_lines: MAX_READ_OUTPUT_LINES, max_command_output_bytes: MAX_COMMAND_OUTPUT_BYTES, guardrails: true, command_runner: nil)
31
32
  @root = Pathname.new(root).realpath
32
33
  @guardrails = guardrails
33
34
  @max_file_bytes = max_file_bytes
34
35
  @max_read_output_bytes = max_read_output_bytes
35
36
  @max_read_output_lines = max_read_output_lines
36
37
  @max_command_output_bytes = max_command_output_bytes
38
+ @command_runner = command_runner
37
39
  end
38
40
 
39
41
  # @return [Pathname] canonical workspace root used as the base for file and shell tools
@@ -162,7 +164,13 @@ module Kward
162
164
  timeout_seconds = DEFAULT_COMMAND_TIMEOUT_SECONDS if timeout_seconds <= 0
163
165
  cancellation&.raise_if_cancelled!
164
166
 
165
- result = LocalCommandRunner.new(timeout_seconds: timeout_seconds, max_output_bytes: @max_command_output_bytes).run(command, cwd: @root.to_s, cancellation: cancellation)
167
+ result = command_runner.run(
168
+ command,
169
+ cwd: @root.to_s,
170
+ timeout_seconds: timeout_seconds,
171
+ max_output_bytes: @max_command_output_bytes,
172
+ cancellation: cancellation
173
+ )
166
174
  return "Error: command timed out after #{timeout_seconds} seconds" if result.timed_out
167
175
 
168
176
  output = +"Exit status: #{result.exit_status}\n"
@@ -170,7 +178,7 @@ module Kward
170
178
  output << "\nSTDERR:\n#{result.stderr}" unless result.stderr.empty?
171
179
  output << "\n... truncated to #{@max_command_output_bytes} bytes" if result.truncated
172
180
  truncate_output(output)
173
- rescue Errno::ENOENT, ArgumentError => e
181
+ rescue Errno::ENOENT, ArgumentError, Sandbox::UnavailableError => e
174
182
  "Error: #{e.message}"
175
183
  end
176
184
 
@@ -181,6 +189,13 @@ module Kward
181
189
 
182
190
  private
183
191
 
192
+ def command_runner
193
+ @command_runner ||= Sandbox::PassthroughRunner.new(
194
+ policy: Sandbox::Policy.new(workspace_root: @root),
195
+ capabilities: Sandbox::RunnerFactory.off_capabilities
196
+ )
197
+ end
198
+
184
199
  def workspace_path(path)
185
200
  target = Pathname.new(path.to_s)
186
201
  target = @root.join(target) unless target.absolute?
@@ -0,0 +1,17 @@
1
+ require_relative "config_files"
2
+ require_relative "sandbox"
3
+ require_relative "workspace"
4
+
5
+ # Namespace for the Kward CLI agent runtime.
6
+ module Kward
7
+ # Builds workspaces with the same user-configured command sandbox in every frontend.
8
+ module WorkspaceFactory
9
+ module_function
10
+
11
+ def build(root:, guardrails: true, config: ConfigFiles.read_config)
12
+ policy = ConfigFiles.sandbox_policy(root, config)
13
+ runner = Sandbox::RunnerFactory.build(policy)
14
+ Workspace.new(root:, guardrails:, command_runner: runner)
15
+ end
16
+ end
17
+ end
@@ -4,6 +4,7 @@ const guideLinks = {
4
4
  'doc/usage.md': 'file.usage.html',
5
5
  'doc/configuration.md': 'file.configuration.html',
6
6
  'doc/authentication.md': 'file.authentication.html',
7
+ 'doc/sandboxing.md': 'file.sandboxing.html',
7
8
  'doc/troubleshooting.md': 'file.troubleshooting.html',
8
9
  'doc/session-management.md': 'file.session-management.html',
9
10
  'doc/memory.md': 'file.memory.html',
@@ -22,7 +22,8 @@ module KwardDocsNavigationData
22
22
  ["Memory", "file.memory.html"],
23
23
  ["Personas", "file.personas.html"],
24
24
  ["Skills", "file.skills.html"],
25
- ["Pan mode", "file.pan.html"]
25
+ ["Pan mode", "file.pan.html"],
26
+ ["Local models", "file.local-models.html"]
26
27
  ]
27
28
  ],
28
29
  [
@@ -43,7 +44,8 @@ module KwardDocsNavigationData
43
44
  ["Extensibility", "file.extensibility.html"],
44
45
  ["Plugins", "file.plugins.html"],
45
46
  ["Lifecycle hooks", "file.lifecycle-hooks.html"],
46
- ["Permissions", "file.permissions.html"]
47
+ ["Permissions", "file.permissions.html"],
48
+ ["Command sandboxing", "file.sandboxing.html"]
47
49
  ]
48
50
  ],
49
51
  [
@@ -30,6 +30,7 @@ module KwardDocsNavigation
30
30
  "doc/configuration.md" => "file.configuration.html",
31
31
  "doc/authentication.md" => "file.authentication.html",
32
32
  "doc/permissions.md" => "file.permissions.html",
33
+ "doc/sandboxing.md" => "file.sandboxing.html",
33
34
  "doc/troubleshooting.md" => "file.troubleshooting.html",
34
35
  "doc/session-management.md" => "file.session-management.html",
35
36
  "doc/tabs.md" => "file.tabs.html",
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kward
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.78.0
4
+ version: 0.79.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kai Wood
@@ -139,6 +139,7 @@ files:
139
139
  - doc/getting-started.md
140
140
  - doc/git.md
141
141
  - doc/lifecycle-hooks.md
142
+ - doc/local-models.md
142
143
  - doc/mcp.md
143
144
  - doc/memory.md
144
145
  - doc/pan.md
@@ -147,6 +148,7 @@ files:
147
148
  - doc/plugins.md
148
149
  - doc/releasing.md
149
150
  - doc/rpc.md
151
+ - doc/sandboxing.md
150
152
  - doc/security.md
151
153
  - doc/session-management.md
152
154
  - doc/shell.md
@@ -314,6 +316,16 @@ files:
314
316
  - lib/kward/rpc/transcript_normalizer.rb
315
317
  - lib/kward/rpc/transport.rb
316
318
  - lib/kward/rpc/turn_context.rb
319
+ - lib/kward/sandbox.rb
320
+ - lib/kward/sandbox/capabilities.rb
321
+ - lib/kward/sandbox/command_runner.rb
322
+ - lib/kward/sandbox/environment.rb
323
+ - lib/kward/sandbox/linux_bubblewrap_runner.rb
324
+ - lib/kward/sandbox/macos_seatbelt_runner.rb
325
+ - lib/kward/sandbox/passthrough_runner.rb
326
+ - lib/kward/sandbox/policy.rb
327
+ - lib/kward/sandbox/runner_factory.rb
328
+ - lib/kward/sandbox/unavailable_runner.rb
317
329
  - lib/kward/scratchpad_runner.rb
318
330
  - lib/kward/session_diff.rb
319
331
  - lib/kward/session_naming.rb
@@ -322,6 +334,7 @@ files:
322
334
  - lib/kward/session_tree_nodes.rb
323
335
  - lib/kward/session_tree_renderer.rb
324
336
  - lib/kward/session_tree_tool_display.rb
337
+ - lib/kward/skills/capture.rb
325
338
  - lib/kward/skills/registry.rb
326
339
  - lib/kward/starter_pack_installer.rb
327
340
  - lib/kward/steering.rb
@@ -359,6 +372,7 @@ files:
359
372
  - lib/kward/update_check.rb
360
373
  - lib/kward/version.rb
361
374
  - lib/kward/workspace.rb
375
+ - lib/kward/workspace_factory.rb
362
376
  - lib/main.rb
363
377
  - templates/default/fulldoc/html/css/kward.css
364
378
  - templates/default/fulldoc/html/full_list.erb
@@ -386,7 +400,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
386
400
  requirements:
387
401
  - - ">="
388
402
  - !ruby/object:Gem::Version
389
- version: '3.2'
403
+ version: '3.4'
390
404
  required_rubygems_version: !ruby/object:Gem::Requirement
391
405
  requirements:
392
406
  - - ">="