kward 0.77.0 → 0.78.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +1 -0
  5. data/doc/code-search.md +9 -6
  6. data/doc/configuration.md +39 -0
  7. data/doc/permissions.md +179 -0
  8. data/doc/plugins.md +58 -0
  9. data/doc/rpc.md +61 -0
  10. data/doc/security.md +4 -0
  11. data/doc/tabs.md +4 -3
  12. data/doc/web-search.md +4 -2
  13. data/lib/kward/auth/anthropic_oauth.rb +2 -2
  14. data/lib/kward/auth/github_oauth.rb +3 -3
  15. data/lib/kward/auth/oauth_helpers.rb +4 -2
  16. data/lib/kward/auth/openai_oauth.rb +2 -1
  17. data/lib/kward/cli/plugins.rb +22 -0
  18. data/lib/kward/cli/rendering.rb +7 -1
  19. data/lib/kward/cli/runtime_helpers.rb +14 -0
  20. data/lib/kward/cli/tabs.rb +140 -34
  21. data/lib/kward/cli.rb +29 -9
  22. data/lib/kward/config_files.rb +10 -0
  23. data/lib/kward/hooks/http_handler.rb +2 -1
  24. data/lib/kward/http.rb +18 -0
  25. data/lib/kward/model/client.rb +33 -11
  26. data/lib/kward/model/payloads.rb +6 -1
  27. data/lib/kward/openrouter_model_cache.rb +2 -1
  28. data/lib/kward/permissions/policy.rb +171 -0
  29. data/lib/kward/plugin_registry.rb +53 -1
  30. data/lib/kward/prompt_interface/approval_prompt.rb +62 -0
  31. data/lib/kward/prompt_interface/question_prompt.rb +12 -3
  32. data/lib/kward/prompt_interface.rb +2 -0
  33. data/lib/kward/rpc/plugin_chat_manager.rb +299 -0
  34. data/lib/kward/rpc/server.rb +35 -0
  35. data/lib/kward/rpc/session_manager.rb +1 -0
  36. data/lib/kward/rpc/transcript_normalizer.rb +14 -5
  37. data/lib/kward/starter_pack_installer.rb +3 -1
  38. data/lib/kward/tab_driver.rb +87 -0
  39. data/lib/kward/tab_store.rb +74 -12
  40. data/lib/kward/tools/code_search.rb +8 -2
  41. data/lib/kward/tools/fetch_content.rb +4 -2
  42. data/lib/kward/tools/fetch_raw.rb +3 -1
  43. data/lib/kward/tools/registry.rb +37 -4
  44. data/lib/kward/tools/search/code.rb +46 -12
  45. data/lib/kward/tools/search/web.rb +60 -17
  46. data/lib/kward/tools/search/web_fetch.rb +206 -38
  47. data/lib/kward/tools/web_search.rb +3 -1
  48. data/lib/kward/update_check.rb +2 -1
  49. data/lib/kward/version.rb +1 -1
  50. data/templates/default/kward_navigation.rb +2 -1
  51. data/templates/default/layout/html/layout.erb +2 -0
  52. data/templates/default/layout/html/setup.rb +1 -0
  53. metadata +7 -1
@@ -12,7 +12,7 @@ module Kward
12
12
  @web_fetch = web_fetch
13
13
  super(
14
14
  "fetch_content",
15
- "Fetch a specific URL and extract readable bounded content.",
15
+ "Fetch a specific URL and extract readable bounded content, including links and simple tables.",
16
16
  properties: {
17
17
  url: {
18
18
  type: "string",
@@ -34,7 +34,9 @@ module Kward
34
34
 
35
35
  # Executes the tool and returns model-facing output text.
36
36
  def call(args, _conversation, cancellation: nil)
37
- @web_fetch.fetch_content(args)
37
+ return @web_fetch.fetch_content(args) unless cancellation
38
+
39
+ @web_fetch.fetch_content(args, cancellation: cancellation)
38
40
  end
39
41
  end
40
42
  end
@@ -33,7 +33,9 @@ module Kward
33
33
 
34
34
  # Executes the tool and returns model-facing output text.
35
35
  def call(args, _conversation, cancellation: nil)
36
- @web_fetch.fetch_raw(args)
36
+ return @web_fetch.fetch_raw(args) unless cancellation
37
+
38
+ @web_fetch.fetch_raw(args, cancellation: cancellation)
37
39
  end
38
40
  end
39
41
  end
@@ -22,6 +22,7 @@ require_relative "search/web"
22
22
  require_relative "search/web_fetch"
23
23
  require_relative "tool_call"
24
24
  require_relative "../mcp/server_config"
25
+ require_relative "../permissions/policy"
25
26
  require_relative "../telemetry/logger"
26
27
  require_relative "../tool_output_compactor"
27
28
  require_relative "../workspace"
@@ -64,7 +65,7 @@ module Kward
64
65
  # @param web_search_enabled [Boolean, nil] override for web search exposure
65
66
  # @param skills [Array<ConfigFiles::Skill>, nil] override discovered skills
66
67
  # @param ask_user_question_enabled [Boolean, nil] override question exposure
67
- def initialize(workspace: Workspace.new, prompt: nil, web_search: WebSearch.new, web_fetch: WebFetch.new, code_search: CodeSearch.new, web_search_enabled: nil, skills: nil, ask_user_question_enabled: nil, allowed_tool_names: nil, tool_output_compactor: ToolOutputCompactor.new, telemetry_logger: TelemetryLogger.new, context_budget_meter: nil, mcp_clients: nil, tool_approval: nil, hook_manager: nil, hook_context: nil)
68
+ def initialize(workspace: Workspace.new, prompt: nil, web_search: WebSearch.new, web_fetch: WebFetch.new, code_search: CodeSearch.new, web_search_enabled: nil, skills: nil, ask_user_question_enabled: nil, allowed_tool_names: nil, tool_output_compactor: ToolOutputCompactor.new, telemetry_logger: TelemetryLogger.new, context_budget_meter: nil, mcp_clients: nil, tool_approval: nil, approval_for_allowed_tools: false, permission_policy: nil, hook_manager: nil, hook_context: nil)
68
69
  @workspace = workspace
69
70
  @prompt = prompt
70
71
  @web_search = web_search
@@ -78,6 +79,8 @@ module Kward
78
79
  @telemetry_logger = telemetry_logger
79
80
  @context_budget_meter = context_budget_meter
80
81
  @tool_approval = tool_approval
82
+ @approval_for_allowed_tools = approval_for_allowed_tools == true
83
+ @permission_policy = permission_policy || Permissions::Policy.from_config(ConfigFiles.read_config)
81
84
  @hook_manager = hook_manager
82
85
  @hook_context = hook_context
83
86
  @mcp_clients = if mcp_clients
@@ -114,10 +117,25 @@ module Kward
114
117
  hook_denied_content(before_tool, "tool call denied: #{name}")
115
118
  elsif before_tool.approval_required? && hook_approval_denied?(before_tool, tool_call, name, args, cancellation)
116
119
  hook_denied_content(before_tool, "tool call approval denied: #{name}")
117
- elsif tool_approval_denied?(tool_call, name, args, cancellation)
118
- "Declined: tool execution denied by user: #{name}"
119
120
  else
120
- execute_tool_with_hooks(tool, name, args, tool_call, conversation, cancellation)
121
+ permission_decision = @permission_policy.decision_for(name, args, source: source_for_tool(tool))
122
+ if permission_decision.denied?
123
+ "Declined: #{permission_decision.reason}: #{name}"
124
+ elsif permission_decision.approval_required?
125
+ approval = permission_approval_result(tool_call, name, args, cancellation)
126
+ if approval == :allow_for_session
127
+ @permission_policy.allow_for_session!(name)
128
+ execute_tool_with_hooks(tool, name, args, tool_call, conversation, cancellation)
129
+ elsif approval == true
130
+ execute_tool_with_hooks(tool, name, args, tool_call, conversation, cancellation)
131
+ else
132
+ approval_denied_content(approval, name)
133
+ end
134
+ elsif @approval_for_allowed_tools && tool_approval_denied?(tool_call, name, args, cancellation)
135
+ "Declined: tool execution denied by user: #{name}"
136
+ else
137
+ execute_tool_with_hooks(tool, name, args, tool_call, conversation, cancellation)
138
+ end
121
139
  end
122
140
  else
123
141
  "Unknown tool: #{name}"
@@ -184,6 +202,8 @@ module Kward
184
202
 
185
203
  def source_for_tool(tool)
186
204
  case tool
205
+ when Tools::MCPTool
206
+ "mcp"
187
207
  when Tools::WebSearch, Tools::FetchContent, Tools::FetchRaw
188
208
  "web"
189
209
  when Tools::ReadSkill
@@ -420,6 +440,19 @@ module Kward
420
440
  (metadata[:source] || metadata["source"]).to_s == "mcp"
421
441
  end
422
442
 
443
+ def permission_approval_result(tool_call, name, args, cancellation)
444
+ return false unless @tool_approval
445
+
446
+ @tool_approval.call(tool_call: tool_call, name: name, args: args, cancellation: cancellation)
447
+ end
448
+
449
+ def approval_denied_content(approval, name)
450
+ message = approval[:denied_message] if approval.is_a?(Hash)
451
+ return "Declined: #{message}" unless message.to_s.empty?
452
+
453
+ "Declined: tool execution denied by user: #{name}"
454
+ end
455
+
423
456
  def tool_approval_denied?(tool_call, name, args, cancellation)
424
457
  return false unless @tool_approval
425
458
 
@@ -33,7 +33,10 @@ module Kward
33
33
  @max_output_bytes = max_output_bytes
34
34
  end
35
35
 
36
- def call(args)
36
+ def call(args = nil, cancellation: nil, **keyword_args)
37
+ args ||= keyword_args
38
+ cancellation&.raise_if_cancelled!
39
+ @cancellation = cancellation
37
40
  action = value(args, "action").to_s
38
41
  return "Error: action must be one of: #{ACTIONS.join(", ")}" unless ACTIONS.include?(action)
39
42
 
@@ -47,6 +50,8 @@ module Kward
47
50
  when "refresh_cache" then refresh_cache(args)
48
51
  when "clear_cache" then clear_cache(args)
49
52
  end
53
+ rescue Cancellation::CancelledError
54
+ raise
50
55
  rescue StandardError => e
51
56
  "Error: code_search failed: #{redact(e.message)}"
52
57
  end
@@ -129,13 +134,16 @@ module Kward
129
134
  query = value(args, "query").to_s
130
135
  return "Error: query is required" if query.strip.empty?
131
136
 
132
- path, = ensure_repo(repo, refresh: false)
137
+ path, revision = synchronized_repo(repo, value(args, "ref") || repo[:requested_ref])
133
138
  max_results = bounded_max_results(value(args, "max_results"))
134
139
  context = bounded_integer(value(args, "context_lines"), DEFAULT_CONTEXT_LINES, 0, 5)
135
140
  matches = search_files(path, query, max_results: max_results, context_lines: context)
136
- return "Error: no matches found in #{repo[:full_name]}" if matches.empty?
141
+ if matches.empty?
142
+ suffix = revision == "unknown" ? "" : " at #{revision}"
143
+ return "Error: no matches found in #{repo[:full_name]}#{suffix}"
144
+ end
137
145
 
138
- lines = ["# Code search", "- Repository: #{repo[:full_name]}", "- Query: #{query}", ""]
146
+ lines = ["# Code search", "- Repository: #{repo[:full_name]}", "- Revision: #{revision}", "- Query: #{query}", ""]
139
147
  matches.each do |match|
140
148
  lines << "## #{match[:path]}:#{match[:line]}"
141
149
  lines << "```"
@@ -148,10 +156,10 @@ module Kward
148
156
  def repo_read(args)
149
157
  repo = require_repo(args)
150
158
  return repo if repo.is_a?(String)
151
- file = value(args, "path") || value(args, "file")
159
+ file = value(args, "path") || value(args, "file") || repo[:requested_path]
152
160
  return "Error: path is required" if file.to_s.empty?
153
161
 
154
- root, = ensure_repo(repo, refresh: false)
162
+ root, revision = synchronized_repo(repo, value(args, "ref") || repo[:requested_ref])
155
163
  target = safe_existing_path(root, file.to_s)
156
164
  return "Error: path is not a file: #{file}" unless File.file?(target)
157
165
  return "Error: file is too large: #{file}" if File.size(target) > MAX_FILE_BYTES
@@ -161,7 +169,7 @@ module Kward
161
169
  lines = File.readlines(target, chomp: true)
162
170
  selected = lines.drop(start_line - 1).first(line_count)
163
171
  numbered = selected.each_with_index.map { |line, index| "#{start_line + index}: #{line}" }
164
- truncate((["# Code read", "- Repository: #{repo[:full_name]}", "- Path: #{file}", ""] + numbered).join("\n"))
172
+ truncate((["# Code read", "- Repository: #{repo[:full_name]}", "- Revision: #{revision}", "- Path: #{file}", ""] + numbered).join("\n"))
165
173
  rescue ArgumentError => e
166
174
  "Error: #{e.message}"
167
175
  end
@@ -231,10 +239,7 @@ module Kward
231
239
  path = cache_path(repo)
232
240
  FileUtils.mkdir_p(@cache_root, mode: 0o700)
233
241
  if Dir.exist?(File.join(path, ".git"))
234
- if refresh
235
- @git_runner.run("fetch", "--depth", "1", "origin", chdir: path)
236
- @git_runner.run("reset", "--hard", "FETCH_HEAD", chdir: path)
237
- end
242
+ synchronize_checkout(path) if refresh
238
243
  return [path, false]
239
244
  end
240
245
 
@@ -243,11 +248,35 @@ module Kward
243
248
  [path, true]
244
249
  end
245
250
 
251
+ def synchronized_repo(repo, ref)
252
+ path = cache_path(repo)
253
+ lock_path = "#{path}.lock"
254
+ FileUtils.mkdir_p(@cache_root, mode: 0o700)
255
+ File.open(lock_path, File::RDWR | File::CREAT, 0o600) do |lock|
256
+ lock.flock(File::LOCK_EX)
257
+ path, created = ensure_repo(repo, refresh: false)
258
+ synchronize_checkout(path, ref: ref) unless created && ref.to_s.empty?
259
+ revision = @git_runner.run("rev-parse", "HEAD", chdir: path).strip
260
+ revision = "unknown" if revision.empty?
261
+ [path, revision]
262
+ end
263
+ end
264
+
265
+ def synchronize_checkout(path, ref: nil)
266
+ if ref.to_s.empty?
267
+ @git_runner.run("fetch", "--depth", "1", "origin", chdir: path)
268
+ else
269
+ @git_runner.run("fetch", "--depth", "1", "origin", ref.to_s, chdir: path)
270
+ end
271
+ @git_runner.run("reset", "--hard", "FETCH_HEAD", chdir: path)
272
+ end
273
+
246
274
  def search_files(root, query, max_results:, context_lines:)
247
275
  matches = []
248
276
  base = File.realpath(root)
249
277
  scanned = 0
250
278
  Dir.glob(File.join(root, "**", "*"), File::FNM_DOTMATCH).sort.each do |path|
279
+ @cancellation&.raise_if_cancelled!
251
280
  next if matches.length >= max_results || scanned >= MAX_SCANNED_FILES
252
281
  next if skip_search_path?(base, path)
253
282
 
@@ -327,7 +356,12 @@ module Kward
327
356
  return nil unless owner.match?(%r{\A[A-Za-z0-9_.-]+\z}) && name.match?(%r{\A[A-Za-z0-9_.-]+\z})
328
357
 
329
358
  full_name = "#{owner}/#{name}"
330
- { full_name: full_name, html_url: "https://github.com/#{full_name}", clone_url: "https://github.com/#{full_name}.git" }
359
+ result = { full_name: full_name, html_url: "https://github.com/#{full_name}", clone_url: "https://github.com/#{full_name}.git" }
360
+ if parts[2] == "blob" && parts.length >= 5
361
+ result[:requested_ref] = parts[3]
362
+ result[:requested_path] = parts[4..].join("/")
363
+ end
364
+ result
331
365
  rescue URI::InvalidURIError
332
366
  nil
333
367
  end
@@ -4,6 +4,7 @@ require "net/http"
4
4
  require "nokogiri"
5
5
  require "uri"
6
6
  require_relative "../../config_files"
7
+ require_relative "../../http"
7
8
 
8
9
  # Namespace for the Kward CLI agent runtime.
9
10
  module Kward
@@ -49,7 +50,9 @@ module Kward
49
50
  true
50
51
  end
51
52
 
52
- def search(args)
53
+ def search(args = nil, cancellation: nil, **keyword_args)
54
+ args ||= keyword_args
55
+ cancellation&.raise_if_cancelled!
53
56
  queries = args_value(args, "queries")
54
57
  return "Error: queries must be an array with 1-#{MAX_QUERIES} strings" unless valid_queries?(queries)
55
58
 
@@ -60,36 +63,44 @@ module Kward
60
63
  provider = normalize_provider(provider_value)
61
64
  return "Error: provider must be one of: #{PROVIDERS.join(", ")}" unless provider
62
65
 
66
+ recency_value = args_value(args, "recency_filter") || args_value(args, "recencyFilter")
67
+ recency = normalize_recency(recency_value)
68
+ return "Error: recency_filter must be one of: day, week, month, year" if recency_value && !recency
69
+
63
70
  options = {
64
71
  max_results: max_results,
65
- recency_filter: normalize_recency(args_value(args, "recency_filter") || args_value(args, "recencyFilter")),
72
+ recency_filter: recency,
66
73
  domain_filter: normalize_domain_filter(args_value(args, "domain_filter") || args_value(args, "domainFilter")),
67
74
  provider: provider
68
75
  }
69
76
 
70
- sections = ["# Web search", "Use fetch_content with a result URL to verify human-readable pages, or fetch_raw for specs, JSON, YAML, XML, and other machine-readable resources."]
77
+ sections = ["# Web search"]
71
78
  failures = []
72
- any_results = false
79
+ succeeded = 0
73
80
 
74
81
  queries.each do |query|
75
- response, error = search_query(query, options)
76
- any_results = true if successful_response?(response)
82
+ cancellation&.raise_if_cancelled!
83
+ response, error = search_query(query, options, cancellation: cancellation)
84
+ succeeded += 1 if successful_response?(response)
77
85
  failures << "#{query}: #{error}" if error && !successful_response?(response)
78
86
  sections << format_query_results(query, response, error)
79
87
  end
80
88
 
81
- unless any_results
89
+ if succeeded.zero?
82
90
  return "Error: web_search found no results\n#{failures.map { |failure| "- #{failure}" }.join("\n")}".strip
83
91
  end
84
92
 
93
+ sections.insert(1, "- Queries: #{queries.length}\n- Succeeded: #{succeeded}\n- Failed: #{queries.length - succeeded}")
94
+ sections.insert(2, "Use fetch_content with a result URL to verify human-readable pages, or fetch_raw for specs, JSON, YAML, XML, and other machine-readable resources.")
85
95
  truncate_output(sections.join("\n\n"))
86
96
  end
87
97
 
88
98
  private
89
99
 
90
- def search_query(query, options)
100
+ def search_query(query, options, cancellation: nil)
91
101
  errors = []
92
102
  provider_order(options[:provider]).each do |provider|
103
+ cancellation&.raise_if_cancelled!
93
104
  begin
94
105
  response = case provider
95
106
  when "exa"
@@ -514,7 +525,7 @@ module Kward
514
525
  lines << answer
515
526
  end
516
527
 
517
- results = response.results || []
528
+ results = unique_results(response.results || [])
518
529
  unless results.empty?
519
530
  lines << "Sources:" unless answer.empty?
520
531
  results.each_with_index do |result, index|
@@ -527,6 +538,25 @@ module Kward
527
538
  lines.join("\n")
528
539
  end
529
540
 
541
+ def unique_results(results)
542
+ seen = {}
543
+ results.each_with_object([]) do |result, unique|
544
+ url = clean_result_url(result.url)
545
+ uri = URI.parse(url)
546
+ next unless %w[http https].include?(uri.scheme) && uri.host
547
+
548
+ uri.fragment = nil
549
+ key = uri.to_s
550
+ next if seen[key]
551
+
552
+ seen[key] = true
553
+ result.url = key
554
+ unique << result
555
+ rescue URI::InvalidURIError
556
+ next
557
+ end
558
+ end
559
+
530
560
  def answer_from_results(results)
531
561
  results.filter_map do |result|
532
562
  excerpt = result.excerpt.to_s.strip
@@ -590,7 +620,7 @@ module Kward
590
620
  {
591
621
  "Accept" => accept,
592
622
  "Accept-Language" => "en-US,en;q=0.9",
593
- "User-Agent" => "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
623
+ "User-Agent" => Http.user_agent,
594
624
  "Sec-Fetch-Dest" => "document",
595
625
  "Sec-Fetch-Mode" => "navigate",
596
626
  "Sec-Fetch-Site" => "none",
@@ -720,14 +750,14 @@ module Kward
720
750
 
721
751
  # HTTP adapter used by web-search providers and fallbacks.
722
752
  class NetHttpClient
723
- Response = Struct.new(:code, :body, :headers, keyword_init: true)
753
+ Response = Struct.new(:code, :body, :headers, :truncated, keyword_init: true)
724
754
 
725
- def initialize(user_agent: nil)
755
+ def initialize(user_agent: Http.user_agent)
726
756
  @user_agent = user_agent
727
757
  end
728
758
 
729
- def get(url, headers: {})
730
- request(url, Net::HTTP::Get, headers: headers)
759
+ def get(url, headers: {}, max_bytes: nil)
760
+ request(url, Net::HTTP::Get, headers: headers, max_bytes: max_bytes)
731
761
  end
732
762
 
733
763
  def get_json(url, headers: {})
@@ -756,15 +786,28 @@ module Kward
756
786
 
757
787
  private
758
788
 
759
- def request(url, request_class, headers: {})
789
+ def request(url, request_class, headers: {}, max_bytes: nil)
760
790
  uri = URI.parse(url)
761
791
  Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: HTTP_TIMEOUT_SECONDS, read_timeout: HTTP_TIMEOUT_SECONDS) do |http|
762
792
  http_request = request_class.new(uri)
763
793
  http_request["User-Agent"] = @user_agent if @user_agent
764
794
  headers.each { |key, value| http_request[key] = value }
765
795
  yield http_request if block_given?
766
- response = http.request(http_request)
767
- Response.new(code: response.code, body: response.body, headers: response.each_header.to_h)
796
+ response = nil
797
+ body = +""
798
+ truncated = false
799
+ http.request(http_request) do |http_response|
800
+ response = http_response
801
+ http_response.read_body do |chunk|
802
+ if max_bytes && body.bytesize + chunk.bytesize > max_bytes
803
+ body << chunk.byteslice(0, max_bytes - body.bytesize)
804
+ truncated = true
805
+ break
806
+ end
807
+ body << chunk
808
+ end
809
+ end
810
+ Response.new(code: response.code, body: body, headers: response.each_header.to_h, truncated: truncated)
768
811
  end
769
812
  end
770
813