kward 0.76.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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +8 -1
  3. data/CHANGELOG.md +62 -0
  4. data/Gemfile.lock +2 -2
  5. data/README.md +13 -4
  6. data/doc/agent-tools.md +0 -3
  7. data/doc/api.md +4 -0
  8. data/doc/code-search.md +9 -6
  9. data/doc/composer.md +219 -0
  10. data/doc/configuration.md +114 -27
  11. data/doc/editor.md +18 -2
  12. data/doc/files.md +1 -1
  13. data/doc/getting-started.md +4 -0
  14. data/doc/git.md +2 -2
  15. data/doc/lifecycle-hooks.md +2 -14
  16. data/doc/pan.md +167 -0
  17. data/doc/permissions.md +179 -0
  18. data/doc/plugins.md +58 -0
  19. data/doc/rpc.md +61 -22
  20. data/doc/security.md +184 -0
  21. data/doc/tabs.md +4 -3
  22. data/doc/usage.md +15 -32
  23. data/doc/web-search.md +4 -2
  24. data/lib/kward/ansi.rb +2 -2
  25. data/lib/kward/auth/anthropic_oauth.rb +2 -2
  26. data/lib/kward/auth/github_oauth.rb +3 -3
  27. data/lib/kward/auth/oauth_helpers.rb +4 -2
  28. data/lib/kward/auth/openai_oauth.rb +2 -1
  29. data/lib/kward/cancellation.rb +2 -2
  30. data/lib/kward/cli/commands.rb +1 -3
  31. data/lib/kward/cli/interactive_turn.rb +0 -17
  32. data/lib/kward/cli/plugins.rb +24 -7
  33. data/lib/kward/cli/prompt_interface.rb +1 -0
  34. data/lib/kward/cli/rendering.rb +7 -1
  35. data/lib/kward/cli/runtime_helpers.rb +11 -35
  36. data/lib/kward/cli/sessions.rb +14 -5
  37. data/lib/kward/cli/settings.rb +22 -0
  38. data/lib/kward/cli/slash_commands.rb +1 -561
  39. data/lib/kward/cli/tabs.rb +154 -36
  40. data/lib/kward/cli.rb +30 -26
  41. data/lib/kward/compactor.rb +3 -3
  42. data/lib/kward/config_files.rb +28 -0
  43. data/lib/kward/ekwsh.rb +0 -4
  44. data/lib/kward/hooks/catalog.rb +0 -5
  45. data/lib/kward/hooks/http_handler.rb +2 -1
  46. data/lib/kward/http.rb +18 -0
  47. data/lib/kward/image_attachments.rb +1 -1
  48. data/lib/kward/model/client.rb +33 -11
  49. data/lib/kward/model/payloads.rb +6 -1
  50. data/lib/kward/openrouter_model_cache.rb +2 -1
  51. data/lib/kward/pan/index.html.erb +459 -64
  52. data/lib/kward/pan/kward_logo.png +0 -0
  53. data/lib/kward/pan/server.rb +248 -23
  54. data/lib/kward/permissions/policy.rb +171 -0
  55. data/lib/kward/plugin_registry.rb +71 -1
  56. data/lib/kward/prompt_interface/approval_prompt.rb +62 -0
  57. data/lib/kward/prompt_interface/editor/auto_indent.rb +31 -23
  58. data/lib/kward/prompt_interface/editor/modes/vibe.rb +72 -3
  59. data/lib/kward/prompt_interface/layout.rb +0 -4
  60. data/lib/kward/prompt_interface/project_browser.rb +40 -1
  61. data/lib/kward/prompt_interface/question_prompt.rb +12 -3
  62. data/lib/kward/prompt_interface/runtime_state.rb +4 -0
  63. data/lib/kward/prompt_interface/selection_prompt.rb +0 -1
  64. data/lib/kward/prompt_interface/transcript_renderer.rb +0 -12
  65. data/lib/kward/prompt_interface.rb +20 -1
  66. data/lib/kward/prompts/commands.rb +1 -3
  67. data/lib/kward/prompts.rb +20 -0
  68. data/lib/kward/rpc/plugin_chat_manager.rb +299 -0
  69. data/lib/kward/rpc/server.rb +48 -41
  70. data/lib/kward/rpc/session_manager.rb +76 -25
  71. data/lib/kward/rpc/tool_event_normalizer.rb +2 -0
  72. data/lib/kward/rpc/tool_metadata.rb +23 -0
  73. data/lib/kward/rpc/transcript_normalizer.rb +18 -5
  74. data/lib/kward/session_store.rb +55 -16
  75. data/lib/kward/skills/registry.rb +21 -1
  76. data/lib/kward/starter_pack_installer.rb +3 -1
  77. data/lib/kward/tab_driver.rb +87 -0
  78. data/lib/kward/tab_store.rb +74 -12
  79. data/lib/kward/tools/base.rb +14 -0
  80. data/lib/kward/tools/code_search.rb +8 -2
  81. data/lib/kward/tools/fetch_content.rb +4 -2
  82. data/lib/kward/tools/fetch_raw.rb +3 -1
  83. data/lib/kward/tools/registry.rb +45 -17
  84. data/lib/kward/tools/search/code.rb +47 -13
  85. data/lib/kward/tools/search/web.rb +61 -18
  86. data/lib/kward/tools/search/web_fetch.rb +206 -38
  87. data/lib/kward/tools/tool_call.rb +0 -4
  88. data/lib/kward/tools/web_search.rb +3 -1
  89. data/lib/kward/transcript_export.rb +1 -1
  90. data/lib/kward/update_check.rb +2 -1
  91. data/lib/kward/version.rb +1 -1
  92. data/templates/default/kward_navigation.rb +7 -2
  93. data/templates/default/layout/html/layout.erb +2 -2
  94. data/templates/default/layout/html/setup.rb +1 -0
  95. metadata +11 -12
  96. data/lib/kward/workers/git_guard.rb +0 -93
  97. data/lib/kward/workers/job.rb +0 -99
  98. data/lib/kward/workers/live_view.rb +0 -49
  99. data/lib/kward/workers/manager.rb +0 -328
  100. data/lib/kward/workers/queue_runner.rb +0 -166
  101. data/lib/kward/workers/queue_store.rb +0 -112
  102. data/lib/kward/workers/store.rb +0 -72
  103. data/lib/kward/workers/tool_policy.rb +0 -23
  104. data/lib/kward/workers/worker.rb +0 -82
  105. data/lib/kward/workers/write_lock.rb +0 -38
  106. data/lib/kward/workers.rb +0 -10
@@ -13,22 +13,28 @@ require_relative "../plugin_registry"
13
13
  require_relative "../prompts/commands"
14
14
  require_relative "../rpc/transcript_normalizer"
15
15
  require_relative "../session_store"
16
+ require_relative "../session_trash"
16
17
  require_relative "../tools/tool_call"
17
18
  require_relative "../tools/registry"
19
+ require_relative "../version"
18
20
  require_relative "../workspace"
19
21
 
20
22
  # Namespace for the Kward CLI agent runtime.
21
23
  module Kward
22
- # Minimal local HTTP server for the experimental Pan web UI.
24
+ # Local HTTP server for the mobile-friendly Pan web UI.
23
25
  class PanServer
24
26
  DEFAULT_HOST = "0.0.0.0"
25
27
  DEFAULT_PORT = 8765
26
28
  MAX_REQUEST_BODY_BYTES = 64 * 1024
29
+ ROUTING_PROBE_ADDRESS = "8.8.8.8"
30
+ ROUTING_PROBE_PORT = 80
27
31
 
28
- def initialize(client:, working_directory:, config: ConfigFiles.read_config, config_dir: ConfigFiles.config_dir, output: $stderr)
32
+ def initialize(client:, working_directory:, config: ConfigFiles.read_config, config_dir: ConfigFiles.config_dir, output: $stderr, udp_socket_class: UDPSocket)
29
33
  @client = client
30
34
  @output = output
35
+ @udp_socket_class = udp_socket_class
31
36
  @workspace = Workspace.new(root: working_directory)
37
+ @full_config = config
32
38
  @config = pan_config(config)
33
39
  @host = @config.fetch("host", DEFAULT_HOST).to_s
34
40
  @port = positive_port(@config.fetch("port", DEFAULT_PORT))
@@ -37,28 +43,22 @@ module Kward
37
43
  raise "Pan mode requires pan_mode.username and pan_mode.password in #{ConfigFiles.config_path}" if @username.empty? || @password.empty?
38
44
 
39
45
  @session_store = SessionStore.new(config_dir: config_dir, cwd: @workspace.root.to_s)
40
- @session = @session_store.create
41
- @conversation = Conversation.new(
42
- workspace_root: @workspace.root.to_s,
46
+ @session = @session_store.create(
47
+ provider: (@client.current_provider if @client.respond_to?(:current_provider)),
43
48
  model: (@client.current_model if @client.respond_to?(:current_model)),
44
49
  reasoning_effort: (@client.current_reasoning_effort if @client.respond_to?(:current_reasoning_effort))
45
50
  )
51
+ @conversation = new_conversation
46
52
  @session.attach(@conversation)
47
- hook_context = lifecycle_hook_context
48
- hook_manager = lifecycle_hook_manager
49
- @agent = Agent.new(
50
- client: @client,
51
- tool_registry: ToolRegistry.new(workspace: @workspace, ask_user_question_enabled: false, hook_manager: hook_manager, hook_context: hook_context),
52
- conversation: @conversation,
53
- hook_manager: hook_manager,
54
- hook_context: hook_context
55
- )
53
+ @agent = build_agent
56
54
  @prompt_queue = Queue.new
57
55
  @subscribers = []
58
56
  @subscribers_mutex = Mutex.new
59
57
  @worker_started = false
60
58
  @active = false
59
+ @pending_turns = 0
61
60
  @state_mutex = Mutex.new
61
+ @session_mutex = Mutex.new
62
62
  end
63
63
 
64
64
  attr_reader :host, :port, :session, :workspace
@@ -91,8 +91,11 @@ module Kward
91
91
  text = prompt.to_s
92
92
  return { ok: false, error: "Prompt is required" } if text.strip.empty?
93
93
 
94
- queued_at = Time.now.utc.iso8601(3)
95
- @prompt_queue << { prompt: text, queued_at: queued_at }
94
+ @session_mutex.synchronize do
95
+ queued_at = Time.now.utc.iso8601(3)
96
+ @state_mutex.synchronize { @pending_turns += 1 }
97
+ @prompt_queue << { prompt: text, queued_at: queued_at }
98
+ end
96
99
  broadcast("queue", queue_payload)
97
100
  { ok: true, queued: @prompt_queue.size, active: active? }
98
101
  end
@@ -103,6 +106,27 @@ module Kward
103
106
 
104
107
  private
105
108
 
109
+ def new_conversation
110
+ Conversation.new(
111
+ workspace_root: @workspace.root.to_s,
112
+ provider: (@client.current_provider if @client.respond_to?(:current_provider)),
113
+ model: (@client.current_model if @client.respond_to?(:current_model)),
114
+ reasoning_effort: (@client.current_reasoning_effort if @client.respond_to?(:current_reasoning_effort))
115
+ )
116
+ end
117
+
118
+ def build_agent
119
+ hook_context = lifecycle_hook_context
120
+ hook_manager = lifecycle_hook_manager
121
+ Agent.new(
122
+ client: @client,
123
+ tool_registry: ToolRegistry.new(workspace: @workspace, ask_user_question_enabled: false, hook_manager: hook_manager, hook_context: hook_context),
124
+ conversation: @conversation,
125
+ hook_manager: hook_manager,
126
+ hook_context: hook_context
127
+ )
128
+ end
129
+
106
130
  def lifecycle_hook_manager
107
131
  manager = Hooks::ConfigLoader.new(ConfigFiles.lifecycle_hooks_config(@workspace.root)).manager
108
132
  manager.on_result = method(:broadcast_lifecycle_hook_event)
@@ -185,7 +209,10 @@ module Kward
185
209
  broadcast("error", { message: e.message })
186
210
  broadcast("turn_finished", queue_payload.merge(status: "failed"))
187
211
  ensure
188
- set_active(false)
212
+ @state_mutex.synchronize do
213
+ @active = false
214
+ @pending_turns -= 1 if @pending_turns.positive?
215
+ end
189
216
  broadcast("queue", queue_payload)
190
217
  end
191
218
 
@@ -201,12 +228,18 @@ module Kward
201
228
  case [request[:method], request[:path]]
202
229
  when ["GET", "/"]
203
230
  write_response(socket, 200, { "Content-Type" => "text/html; charset=utf-8" }, render_index)
231
+ when ["GET", "/kward-logo.png"]
232
+ write_response(socket, 200, { "Content-Type" => "image/png", "Cache-Control" => "public, max-age=86400" }, File.binread(File.join(__dir__, "kward_logo.png")))
204
233
  when ["GET", "/transcript"]
205
- write_json(socket, 200, transcript: transcript_items, session: { id: @session.id, path: @session.path }, workspace: @workspace.root.to_s)
234
+ write_json(socket, 200, transcript: transcript_items, session: active_session_payload, workspace: @workspace.root.to_s)
235
+ when ["GET", "/sessions"]
236
+ write_json(socket, 200, sessions: session_payloads, activeSessionId: @session.id)
206
237
  when ["GET", "/events"]
207
238
  stream_events(socket)
208
239
  when ["POST", "/turn"]
209
- handle_turn(socket, request[:body])
240
+ handle_turn(socket, request[:body], request[:headers])
241
+ when ["POST", "/sessions"]
242
+ handle_session_action(socket, request[:body], request[:headers])
210
243
  else
211
244
  write_response(socket, 404, { "Content-Type" => "text/plain; charset=utf-8" }, "Not found\n")
212
245
  end
@@ -254,7 +287,9 @@ module Kward
254
287
  left.bytes.zip(right.bytes).reduce(0) { |memo, pair| memo | (pair[0] ^ pair[1]) }.zero?
255
288
  end
256
289
 
257
- def handle_turn(socket, body)
290
+ def handle_turn(socket, body, headers)
291
+ return write_json(socket, 415, ok: false, error: "Content-Type must be application/json") unless json_request?(headers)
292
+
258
293
  params = JSON.parse(body.empty? ? "{}" : body)
259
294
  result = enqueue_prompt(params["prompt"])
260
295
  status = result[:ok] ? 202 : 422
@@ -263,6 +298,155 @@ module Kward
263
298
  write_json(socket, 400, ok: false, error: "Invalid JSON")
264
299
  end
265
300
 
301
+ def handle_session_action(socket, body, headers)
302
+ return write_json(socket, 415, ok: false, error: "Content-Type must be application/json") unless json_request?(headers)
303
+
304
+ params = JSON.parse(body.empty? ? "{}" : body)
305
+ result = update_session(params)
306
+ write_json(socket, result.delete(:status), result)
307
+ rescue JSON::ParserError
308
+ write_json(socket, 400, ok: false, error: "Invalid JSON")
309
+ end
310
+
311
+ def update_session(params)
312
+ return { status: 409, ok: false, error: "Wait for queued turns to finish before changing sessions" } if turns_pending?
313
+
314
+ result = @session_mutex.synchronize do
315
+ return { status: 409, ok: false, error: "Wait for queued turns to finish before changing sessions" } if turns_pending?
316
+
317
+ case params["action"]
318
+ when "new" then create_session
319
+ when "resume" then resume_session(params["id"])
320
+ when "rename" then rename_session(params["id"], params["name"])
321
+ when "delete" then delete_session(params["id"])
322
+ else { status: 422, ok: false, error: "Unknown session action" }
323
+ end
324
+ end
325
+ broadcast("session_changed", result[:session]) if result[:ok] && result[:session]
326
+ result
327
+ rescue StandardError => e
328
+ { status: 422, ok: false, error: e.message }
329
+ end
330
+
331
+ def create_session
332
+ previous = @session
333
+ session = @session_store.create(
334
+ provider: (@client.current_provider if @client.respond_to?(:current_provider)),
335
+ model: (@client.current_model if @client.respond_to?(:current_model)),
336
+ reasoning_effort: (@client.current_reasoning_effort if @client.respond_to?(:current_reasoning_effort))
337
+ )
338
+ conversation = new_conversation
339
+ session.attach(conversation)
340
+ activate_session(session, conversation)
341
+ previous.delete_if_unused
342
+ { status: 200, ok: true, session: active_session_payload }
343
+ end
344
+
345
+ def resume_session(id)
346
+ info = find_session(id)
347
+ return { status: 404, ok: false, error: "Session not found" } unless info
348
+ return { status: 200, ok: true, session: active_session_payload } if info.id == @session.id
349
+
350
+ previous = @session
351
+ session, conversation = @session_store.load(
352
+ info.path,
353
+ workspace: @workspace,
354
+ provider: (@client.current_provider if @client.respond_to?(:current_provider)),
355
+ model: (@client.current_model if @client.respond_to?(:current_model)),
356
+ reasoning_effort: (@client.current_reasoning_effort if @client.respond_to?(:current_reasoning_effort))
357
+ )
358
+ activate_session(session, conversation)
359
+ previous.delete_if_unused
360
+ { status: 200, ok: true, session: active_session_payload }
361
+ end
362
+
363
+ def rename_session(id, name)
364
+ clean_name = name.to_s.strip
365
+ return { status: 422, ok: false, error: "Session name is too long" } if clean_name.length > 120
366
+
367
+ target = id.to_s == @session.id ? @session : find_session(id)
368
+ return { status: 404, ok: false, error: "Session not found" } unless target
369
+
370
+ session = target.respond_to?(:rename) ? target : @session_store.load(target.path, workspace: @workspace).first
371
+ session.rename(clean_name)
372
+ @session.name = session.name if session.id == @session.id
373
+ { status: 200, ok: true, session: session_payload(session) }
374
+ end
375
+
376
+ def delete_session(id)
377
+ if id.to_s == @session.id
378
+ deleted_id = @session.id
379
+ deleted_path = @session.path
380
+ create_session
381
+ SessionTrash.new.delete(deleted_path)
382
+ return { status: 200, ok: true, deletedSessionId: deleted_id, session: active_session_payload }
383
+ end
384
+
385
+ info = find_session(id)
386
+ return { status: 404, ok: false, error: "Session not found" } unless info
387
+
388
+ SessionTrash.new.delete(info.path)
389
+ { status: 200, ok: true, deletedSessionId: info.id }
390
+ end
391
+
392
+ def activate_session(session, conversation)
393
+ @session = session
394
+ @conversation = conversation
395
+ @agent = build_agent
396
+ @session_store.remember_last_session(session)
397
+ end
398
+
399
+ def find_session(id)
400
+ session_infos.find { |info| info.id.to_s == id.to_s }
401
+ end
402
+
403
+ def session_infos
404
+ @session_store.recent(limit: 50, keep_empty_path: @session.path)
405
+ end
406
+
407
+ def session_payloads
408
+ sessions = session_infos.map { |info| session_payload(info) }
409
+ sessions.unshift(active_session_payload) unless sessions.any? { |item| item[:id] == @session.id }
410
+ sessions.sort_by { |item| item[:modifiedAt].to_s }.reverse
411
+ end
412
+
413
+ def active_session_payload
414
+ info = session_infos.find { |item| item.id == @session.id }
415
+ return session_payload(info).merge(active: true, path: @session.path) if info
416
+
417
+ session_payload(@session).merge(
418
+ active: true,
419
+ path: @session.path,
420
+ modifiedAt: File.mtime(@session.path).utc.iso8601(3),
421
+ preview: transcript_items.find { |item| item[:role] == "user" }&.fetch(:text, "") || "",
422
+ messageCount: transcript_items.length
423
+ )
424
+ end
425
+
426
+ def session_payload(session)
427
+ name = session.name.to_s.strip
428
+ preview = session.respond_to?(:first_message) ? session.first_message.to_s : ""
429
+ {
430
+ id: session.id,
431
+ name: name.empty? ? nil : name,
432
+ title: name.empty? ? (preview.empty? ? "New session" : preview) : name,
433
+ preview: preview,
434
+ createdAt: session.created_at&.utc&.iso8601(3),
435
+ modifiedAt: (session.respond_to?(:modified_at) ? session.modified_at&.utc&.iso8601(3) : nil),
436
+ messageCount: (session.respond_to?(:message_count) ? session.message_count.to_i : 0),
437
+ model: (session.respond_to?(:model) ? session.model : @conversation.model),
438
+ active: session.id == @session.id
439
+ }
440
+ end
441
+
442
+ def turns_pending?
443
+ @state_mutex.synchronize { @pending_turns.positive? }
444
+ end
445
+
446
+ def json_request?(headers)
447
+ headers["content-type"].to_s.downcase.start_with?("application/json")
448
+ end
449
+
266
450
  def stream_events(socket)
267
451
  subscriber = Queue.new
268
452
  subscribe(subscriber)
@@ -316,11 +500,38 @@ module Kward
316
500
 
317
501
  def render_index
318
502
  @workspace_root = @workspace.root.to_s
503
+ @workspace_label = pan_workspace_label
504
+ @assistant_label = assistant_label
319
505
  @session_path = @session.path
506
+ @version = Kward::VERSION
320
507
  template = File.read(File.join(__dir__, "index.html.erb"))
321
508
  ERB.new(template).result(binding)
322
509
  end
323
510
 
511
+ def assistant_label
512
+ ConfigFiles.active_persona_label(
513
+ workspace_root: @conversation.workspace_root,
514
+ model: @conversation.model,
515
+ config: @full_config
516
+ ) || "Assistant"
517
+ rescue StandardError
518
+ "Assistant"
519
+ end
520
+
521
+ def pan_workspace_label
522
+ root = File.expand_path(@workspace.root.to_s)
523
+ home = Dir.home
524
+ if root == home || root.start_with?("#{home}/")
525
+ relative = root.delete_prefix(home).delete_prefix("/")
526
+ return "~" if relative.empty?
527
+ return "~/#{relative}" unless relative.include?("/")
528
+ end
529
+
530
+ [File.basename(File.dirname(root)), File.basename(root)].reject(&:empty?).join("/")
531
+ rescue StandardError
532
+ @workspace.root.to_s
533
+ end
534
+
324
535
  def pan_config(config)
325
536
  values = config["pan_mode"]
326
537
  values.is_a?(Hash) ? values : {}
@@ -332,7 +543,19 @@ module Kward
332
543
  end
333
544
 
334
545
  def display_host
335
- @host == "0.0.0.0" ? "<lan-address>" : @host
546
+ return @host unless @host == "0.0.0.0"
547
+
548
+ lan_address || "<lan-address>"
549
+ end
550
+
551
+ def lan_address
552
+ socket = @udp_socket_class.new
553
+ socket.connect(ROUTING_PROBE_ADDRESS, ROUTING_PROBE_PORT)
554
+ socket.addr.last
555
+ rescue SocketError, SystemCallError
556
+ nil
557
+ ensure
558
+ socket&.close
336
559
  end
337
560
 
338
561
  def set_active(value)
@@ -385,9 +608,9 @@ module Kward
385
608
  text.to_s.empty? ? nil : { role: "reasoning", label: "Reasoning", text: text }
386
609
  when "text"
387
610
  text = part[:text] || part["text"]
388
- text.to_s.empty? ? nil : { role: "assistant", label: "Assistant", text: text }
611
+ text.to_s.empty? ? nil : { role: "assistant", label: assistant_label, text: text }
389
612
  when "image"
390
- { role: "assistant", label: "Assistant", text: image_part_text(part) }
613
+ { role: "assistant", label: assistant_label, text: image_part_text(part) }
391
614
  when "toolCall"
392
615
  { role: "tool", label: "Tool", text: tool_call_part_text(part) }
393
616
  end
@@ -442,6 +665,8 @@ module Kward
442
665
  when 400 then "Bad Request"
443
666
  when 401 then "Unauthorized"
444
667
  when 404 then "Not Found"
668
+ when 409 then "Conflict"
669
+ when 415 then "Unsupported Media Type"
445
670
  when 422 then "Unprocessable Content"
446
671
  else "Internal Server Error"
447
672
  end
@@ -0,0 +1,171 @@
1
+ require "uri"
2
+
3
+ # Namespace for Kward's permission-policy runtime.
4
+ module Kward
5
+ module Permissions
6
+ # Evaluates the opt-in permission policy for model-requested tool calls.
7
+ #
8
+ # The policy is intentionally a decision layer, not a process sandbox. It
9
+ # can prevent Kward from starting a tool, but it cannot constrain a shell
10
+ # command after it has begun. OS-enforced sandboxing belongs at a lower
11
+ # process boundary.
12
+ class Policy
13
+ MODES = %w[ask read-only workspace-write deny-by-default].freeze
14
+ NETWORK_TOOLS = %w[web_search fetch_content fetch_raw].freeze
15
+ FILE_CHANGE_TOOLS = %w[write_file edit_file].freeze
16
+
17
+ Decision = Struct.new(:action, :reason, keyword_init: true) do
18
+ def allowed?
19
+ action == :allow
20
+ end
21
+
22
+ def approval_required?
23
+ action == :ask
24
+ end
25
+
26
+ def denied?
27
+ action == :deny
28
+ end
29
+ end
30
+
31
+ def self.from_config(config)
32
+ permissions = config["permissions"].is_a?(Hash) ? config["permissions"] : {}
33
+ new(
34
+ enabled: permissions["enabled"] == true,
35
+ mode: permissions["mode"],
36
+ allow: permissions["allow"],
37
+ ask: permissions["ask"],
38
+ deny: permissions["deny"],
39
+ write_scopes: permissions.key?("write_scopes") ? permissions["write_scopes"] : nil
40
+ )
41
+ end
42
+
43
+ def initialize(enabled: false, mode: "ask", allow: [], ask: [], deny: [], write_scopes: nil)
44
+ @enabled = enabled == true
45
+ @mode = normalize_mode(mode)
46
+ @allow = normalize_rules(allow)
47
+ @ask = normalize_rules(ask)
48
+ @deny = normalize_rules(deny)
49
+ @write_scopes = normalize_scopes(write_scopes)
50
+ @session_allowed_tools = {}
51
+ end
52
+
53
+ def enabled?
54
+ @enabled
55
+ end
56
+
57
+ # Allows this model tool for the remaining lifetime of this policy object.
58
+ # A matching deny or ask rule still takes precedence over this temporary grant.
59
+ def allow_for_session!(tool_name)
60
+ @session_allowed_tools[tool_name.to_s] = true
61
+ end
62
+
63
+ def decision_for(tool_name, arguments, source: nil)
64
+ return Decision.new(action: :allow, reason: "permissions disabled") unless enabled?
65
+
66
+ request = request_for(tool_name, arguments, source: source)
67
+ return Decision.new(action: :deny, reason: "matched deny rule") if matches?(@deny, request)
68
+ return Decision.new(action: :ask, reason: "matched ask rule") if matches?(@ask, request)
69
+ return Decision.new(action: :allow, reason: "matched allow rule") if matches?(@allow, request)
70
+ return Decision.new(action: :allow, reason: "allowed for this session") if @session_allowed_tools[request.fetch("tool")]
71
+
72
+ default_decision(request)
73
+ end
74
+
75
+ private
76
+
77
+ def normalize_mode(mode)
78
+ value = mode.to_s
79
+ MODES.include?(value) ? value : "ask"
80
+ end
81
+
82
+ def normalize_rules(rules)
83
+ Array(rules).filter_map do |rule|
84
+ next unless rule.is_a?(Hash)
85
+
86
+ rule.transform_keys(&:to_s).slice("tool", "path", "host", "command", "source")
87
+ end
88
+ end
89
+
90
+ def normalize_scopes(scopes)
91
+ return nil if scopes.nil?
92
+
93
+ Array(scopes).map(&:to_s).reject(&:empty?)
94
+ end
95
+
96
+ def request_for(tool_name, arguments, source:)
97
+ name = tool_name.to_s
98
+ args = arguments.to_h
99
+ {
100
+ "tool" => name,
101
+ "path" => args["path"] || args[:path],
102
+ "host" => request_host(name, args),
103
+ "command" => args["command"] || args[:command],
104
+ "source" => source.to_s
105
+ }.compact.transform_values(&:to_s)
106
+ end
107
+
108
+ def request_host(name, arguments)
109
+ return nil unless %w[fetch_content fetch_raw].include?(name)
110
+
111
+ URI.parse((arguments["url"] || arguments[:url]).to_s).host
112
+ rescue URI::InvalidURIError
113
+ nil
114
+ end
115
+
116
+ def matches?(rules, request)
117
+ rules.any? { |rule| rule_matches?(rule, request) }
118
+ end
119
+
120
+ def rule_matches?(rule, request)
121
+ return false if rule.empty?
122
+
123
+ rule.all? do |field, pattern|
124
+ value = request[field]
125
+ value && glob_match?(pattern.to_s, value)
126
+ end
127
+ end
128
+
129
+ def glob_match?(pattern, value)
130
+ expression = Regexp.escape(pattern).gsub("\\*\\*", ".*").gsub("\\*", "[^/]*")
131
+ Regexp.new("\\A#{expression}\\z").match?(value)
132
+ end
133
+
134
+ def default_decision(request)
135
+ return Decision.new(action: :deny, reason: "read-only mode") if @mode == "read-only" && risky?(request)
136
+ return Decision.new(action: :deny, reason: "deny-by-default mode") if @mode == "deny-by-default" && risky?(request)
137
+
138
+ if file_change?(request) && outside_write_scopes?(request)
139
+ return Decision.new(action: :deny, reason: "path outside write scopes")
140
+ end
141
+
142
+ if @mode == "workspace-write" && file_change?(request)
143
+ return Decision.new(action: :allow, reason: "workspace write mode")
144
+ end
145
+
146
+ return Decision.new(action: :allow, reason: "read-only tool") unless risky?(request)
147
+
148
+ Decision.new(action: :ask, reason: "#{request.fetch("tool")} requires approval")
149
+ end
150
+
151
+ def file_change?(request)
152
+ FILE_CHANGE_TOOLS.include?(request.fetch("tool"))
153
+ end
154
+
155
+ def network?(request)
156
+ NETWORK_TOOLS.include?(request.fetch("tool")) || request["source"] == "mcp"
157
+ end
158
+
159
+ def risky?(request)
160
+ file_change?(request) || request.fetch("tool") == "run_shell_command" || network?(request)
161
+ end
162
+
163
+ def outside_write_scopes?(request)
164
+ return false if @write_scopes.nil?
165
+
166
+ path = request["path"]
167
+ path.nil? || !@write_scopes.any? { |scope| glob_match?(scope, path) }
168
+ end
169
+ end
170
+ end
171
+ end