kward 0.80.1 → 0.82.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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +2 -2
  5. data/Rakefile +44 -4
  6. data/doc/composer.md +6 -9
  7. data/doc/configuration.md +13 -3
  8. data/doc/files.md +8 -2
  9. data/doc/permissions.md +1 -1
  10. data/doc/releasing.md +71 -38
  11. data/doc/rpc.md +2 -1
  12. data/doc/sandboxing.md +4 -4
  13. data/doc/security.md +6 -9
  14. data/doc/shell.md +161 -196
  15. data/doc/skills.md +17 -5
  16. data/doc/tabs.md +1 -1
  17. data/doc/usage.md +6 -5
  18. data/kward.gemspec +1 -1
  19. data/lib/kward/adaptive_pty_output_sink.rb +183 -0
  20. data/lib/kward/ansi.rb +9 -1
  21. data/lib/kward/cli/commands.rb +7 -0
  22. data/lib/kward/cli/git.rb +5 -1
  23. data/lib/kward/cli/interactive_turn.rb +1 -1
  24. data/lib/kward/cli/plugins.rb +1 -1
  25. data/lib/kward/cli/project_skills.rb +99 -0
  26. data/lib/kward/cli/project_skills_commands.rb +87 -0
  27. data/lib/kward/cli/prompt_interface.rb +18 -4
  28. data/lib/kward/cli/rendering.rb +41 -3
  29. data/lib/kward/cli/runtime_helpers.rb +222 -21
  30. data/lib/kward/cli/sessions.rb +6 -4
  31. data/lib/kward/cli/settings.rb +5 -13
  32. data/lib/kward/cli/slash_commands.rb +6 -0
  33. data/lib/kward/cli/tabs.rb +62 -5
  34. data/lib/kward/cli.rb +21 -1
  35. data/lib/kward/config_files.rb +9 -4
  36. data/lib/kward/conversation.rb +8 -5
  37. data/lib/kward/ekwsh.rb +37 -16
  38. data/lib/kward/image_attachments.rb +98 -15
  39. data/lib/kward/interactive_pty_runner.rb +102 -30
  40. data/lib/kward/prompt_interface/composer_controller.rb +15 -3
  41. data/lib/kward/prompt_interface/composer_renderer.rb +27 -0
  42. data/lib/kward/prompt_interface/editor/controller.rb +10 -6
  43. data/lib/kward/prompt_interface/editor/modes/emacs.rb +2 -2
  44. data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
  45. data/lib/kward/prompt_interface/git_prompt.rb +1 -1
  46. data/lib/kward/prompt_interface/key_handler.rb +100 -43
  47. data/lib/kward/prompt_interface/overlay_renderer.rb +13 -0
  48. data/lib/kward/prompt_interface/project_browser.rb +162 -3
  49. data/lib/kward/prompt_interface/prompt_renderer.rb +15 -6
  50. data/lib/kward/prompt_interface/question_prompt.rb +1 -1
  51. data/lib/kward/prompt_interface/screen.rb +40 -15
  52. data/lib/kward/prompt_interface/selection_prompt.rb +5 -5
  53. data/lib/kward/prompt_interface/transcript_renderer.rb +4 -4
  54. data/lib/kward/prompt_interface.rb +167 -44
  55. data/lib/kward/prompts/commands.rb +2 -0
  56. data/lib/kward/prompts.rb +6 -6
  57. data/lib/kward/pty_output_sink.rb +45 -0
  58. data/lib/kward/pty_transcript_normalizer.rb +93 -0
  59. data/lib/kward/rpc/server.rb +1 -0
  60. data/lib/kward/session_catalog.rb +87 -0
  61. data/lib/kward/session_store.rb +97 -7
  62. data/lib/kward/skills/registry.rb +52 -2
  63. data/lib/kward/skills/trust_coordinator.rb +45 -0
  64. data/lib/kward/skills/trust_store.rb +107 -0
  65. data/lib/kward/terminal_image_support.rb +116 -0
  66. data/lib/kward/terminal_sequences.rb +43 -0
  67. data/lib/kward/version.rb +1 -1
  68. metadata +11 -4
  69. data/.github/workflows/ci.yml +0 -48
  70. data/.github/workflows/pages.yml +0 -48
@@ -0,0 +1,45 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Forwards PTY output immediately and optionally keeps a bounded byte copy.
4
+ #
5
+ # The runner only depends on the sink's `write` method and optional `flush`
6
+ # method. Capture is deliberately byte-oriented so PTY chunk boundaries have
7
+ # no effect on the retained output.
8
+ class PassthroughPtyOutputSink
9
+ attr_reader :captured_output
10
+
11
+ def initialize(output:, max_capture_bytes: nil)
12
+ @output = output
13
+ @max_capture_bytes = max_capture_bytes
14
+ @captured_output = max_capture_bytes ? +"".b : nil
15
+ @truncated = false
16
+ end
17
+
18
+ def write(chunk)
19
+ @output.write(chunk)
20
+ capture(chunk)
21
+ end
22
+
23
+ def flush
24
+ @output.flush if @output.respond_to?(:flush)
25
+ end
26
+
27
+ def truncated?
28
+ @truncated
29
+ end
30
+
31
+ private
32
+
33
+ def capture(chunk)
34
+ return unless @captured_output
35
+
36
+ remaining = @max_capture_bytes - @captured_output.bytesize
37
+ if chunk.bytesize > remaining
38
+ @captured_output << chunk.byteslice(0, remaining) if remaining.positive?
39
+ @truncated = true
40
+ else
41
+ @captured_output << chunk
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,93 @@
1
+ require_relative "ansi"
2
+
3
+ # Namespace for the Kward CLI agent runtime.
4
+ module Kward
5
+ # Reduces safe, line-oriented PTY redraws to transcript-friendly text.
6
+ class PtyTranscriptNormalizer
7
+ HORIZONTAL_REDRAW_PATTERN = /\r|\e\[[0-9;]*[CDGK`]/.freeze
8
+
9
+ def self.normalize(text)
10
+ text.split("\n", -1).map do |line|
11
+ if line.match?(HORIZONTAL_REDRAW_PATTERN)
12
+ Line.new(line).render
13
+ else
14
+ ANSI.sanitize_transcript(line)
15
+ end
16
+ end.join("\n")
17
+ end
18
+
19
+ # Applies the horizontal cursor controls accepted by AdaptivePtyOutputSink
20
+ # without modeling a complete terminal screen.
21
+ class Line
22
+ def initialize(text)
23
+ @text = text
24
+ @cells = []
25
+ @cursor = 0
26
+ end
27
+
28
+ def render
29
+ ANSI.scan_escape_tokens(@text).each do |token|
30
+ token[:escape] ? apply_escape(token[:text]) : write_text(token[:text])
31
+ end
32
+ @cells.join.rstrip
33
+ end
34
+
35
+ private
36
+
37
+ def apply_escape(sequence)
38
+ final = sequence[-1]
39
+ return set_column(sequence) if final == "G" || final == "`"
40
+ return move_cursor(sequence, 1) if final == "C"
41
+ return move_cursor(sequence, -1) if final == "D"
42
+ return erase_line(sequence) if final == "K"
43
+ end
44
+
45
+ def set_column(sequence)
46
+ @cursor = [parameter(sequence, default: 1), 1].max - 1
47
+ end
48
+
49
+ def move_cursor(sequence, direction)
50
+ distance = [parameter(sequence, default: 1), 1].max
51
+ @cursor = [@cursor + (distance * direction), 0].max
52
+ end
53
+
54
+ def erase_line(sequence)
55
+ case parameter(sequence, default: 0)
56
+ when 1
57
+ fill_to_cursor
58
+ 0.upto(@cursor) { |index| @cells[index] = " " }
59
+ when 2
60
+ @cells.clear
61
+ else
62
+ @cells.slice!(@cursor..) if @cursor < @cells.length
63
+ end
64
+ end
65
+
66
+ def parameter(sequence, default:)
67
+ value = sequence[2...-1].to_s.split(";", 2).first.to_s
68
+ value.empty? ? default : value.to_i
69
+ end
70
+
71
+ def write_text(text)
72
+ text.each_char do |character|
73
+ case character
74
+ when "\r"
75
+ @cursor = 0
76
+ when "\b"
77
+ @cursor = [@cursor - 1, 0].max
78
+ when "\t"
79
+ @cursor = ((@cursor / 8) + 1) * 8
80
+ else
81
+ fill_to_cursor
82
+ @cells[@cursor] = character
83
+ @cursor += 1
84
+ end
85
+ end
86
+ end
87
+
88
+ def fill_to_cursor
89
+ @cells.concat([" "] * (@cursor - @cells.length)) if @cursor > @cells.length
90
+ end
91
+ end
92
+ end
93
+ end
@@ -579,6 +579,7 @@ module Kward
579
579
  stability: { protocol: "stable", compatibility: "additive-fields-unless-protocol-version-changes", experimentalCapabilities: [] },
580
580
  commands: { supported: true, methods: COMMAND_METHODS, method: COMMAND_METHODS[0], runMethod: COMMAND_METHODS[1], sources: ["builtin", "prompt", "skill", "plugin"], executableSources: ["builtin", "plugin"] },
581
581
  skillCapture: { supported: true, methods: SKILL_CAPTURE_METHODS, destination: "personal", source: "savedSessionActiveLeaf", reviewRequired: true, overwrite: "explicit", autoActivate: false },
582
+ projectSkillTrust: { supported: false, trustRequired: true, reason: "RPC has no interactive trust decision bridge; project skills remain skipped unless globally enabled." },
582
583
  mcp: {
583
584
  supported: true,
584
585
  transport: "stdio",
@@ -0,0 +1,87 @@
1
+ require "json"
2
+ require_relative "private_file"
3
+
4
+ # Namespace for the Kward CLI agent runtime.
5
+ module Kward
6
+ # Rebuildable lightweight summaries for persisted session event logs.
7
+ #
8
+ # Session JSONL files remain authoritative. Catalog entries are accepted only
9
+ # while their source fingerprint matches and may be deleted at any time.
10
+ class SessionCatalog
11
+ VERSION = 1
12
+ INDEX_DIRECTORY = ".index"
13
+ FILENAME = "sessions.json"
14
+
15
+ def initialize(session_dir:)
16
+ @path = File.join(session_dir, INDEX_DIRECTORY, FILENAME)
17
+ @entries = load_entries
18
+ @dirty = false
19
+ end
20
+
21
+ def fetch(path)
22
+ entry = @entries[entry_key(path)]
23
+ return nil unless entry.is_a?(Hash)
24
+ return nil unless entry["source"] == fingerprint(path)
25
+
26
+ entry["summary"] if entry["summary"].is_a?(Hash)
27
+ rescue StandardError
28
+ nil
29
+ end
30
+
31
+ def write(path, summary, fingerprint: self.fingerprint(path))
32
+ @entries[entry_key(path)] = {
33
+ "source" => fingerprint,
34
+ "summary" => summary
35
+ }
36
+ @dirty = true
37
+ end
38
+
39
+ def remove(path)
40
+ @dirty = true if @entries.delete(entry_key(path))
41
+ end
42
+
43
+ def retain(paths)
44
+ keys = paths.to_h { |path| [entry_key(path), true] }
45
+ previous_size = @entries.size
46
+ @entries.delete_if { |key, _entry| !keys[key] }
47
+ @dirty = true if @entries.size != previous_size
48
+ end
49
+
50
+ def flush
51
+ return unless @dirty
52
+
53
+ PrivateFile.write_json(@path, {
54
+ "version" => VERSION,
55
+ "entries" => @entries
56
+ })
57
+ @dirty = false
58
+ rescue StandardError
59
+ nil
60
+ end
61
+
62
+ def fingerprint(path)
63
+ stat = File.stat(path)
64
+ {
65
+ "size" => stat.size,
66
+ "inode" => stat.ino,
67
+ "mtimeSeconds" => stat.mtime.to_i,
68
+ "mtimeNanoseconds" => stat.mtime.nsec
69
+ }
70
+ end
71
+
72
+ private
73
+
74
+ def load_entries
75
+ data = JSON.parse(File.read(@path))
76
+ return {} unless data["version"] == VERSION && data["entries"].is_a?(Hash)
77
+
78
+ data["entries"]
79
+ rescue StandardError
80
+ {}
81
+ end
82
+
83
+ def entry_key(path)
84
+ File.basename(path)
85
+ end
86
+ end
87
+ end
@@ -9,6 +9,7 @@ require_relative "message_access"
9
9
  require_relative "message_text"
10
10
  require_relative "private_file"
11
11
  require_relative "rpc/tool_event_normalizer"
12
+ require_relative "session_catalog"
12
13
  require_relative "tools/tool_call"
13
14
  require_relative "workspace"
14
15
 
@@ -204,6 +205,7 @@ module Kward
204
205
  def initialize(config_dir: ConfigFiles.config_dir, cwd: Dir.pwd)
205
206
  @config_dir = config_dir
206
207
  @cwd = File.expand_path(cwd)
208
+ @session_catalog = SessionCatalog.new(session_dir: session_dir)
207
209
  end
208
210
 
209
211
  # @return [String] workspace directory this store lists and creates sessions for
@@ -297,7 +299,7 @@ module Kward
297
299
  # `workspace` is used both for the active root and to restore read-before-write
298
300
  # paths from successful read tool results. If a session moved workspaces, load
299
301
  # it through `session_location` first so the original cwd is respected.
300
- def load(path, workspace: Workspace.new, provider: nil, model: nil, reasoning_effort: nil)
302
+ def load(path, workspace: Workspace.new, provider: nil, model: nil, reasoning_effort: nil, project_skill_paths: nil)
301
303
  resolved_path = resolve_session_path(path)
302
304
  records = records_from_file(resolved_path)
303
305
  header = session_header(records, resolved_path)
@@ -317,7 +319,8 @@ module Kward
317
319
  model: runtime["model"] || model,
318
320
  reasoning_effort: runtime["reasoningEffort"] || reasoning_effort,
319
321
  session_memories: memory_state["sessionMemories"],
320
- last_memory_retrieval: memory_state["lastRetrieval"]
322
+ last_memory_retrieval: memory_state["lastRetrieval"],
323
+ project_skill_paths: project_skill_paths
321
324
  )
322
325
  restore_tool_output_artifacts(records, conversation)
323
326
  conversation.mark_last_entry_compaction! if latest_record_type(records) == "compaction"
@@ -398,6 +401,8 @@ module Kward
398
401
  return false unless unused_session_file?(path)
399
402
 
400
403
  File.delete(path)
404
+ @session_catalog.remove(path)
405
+ @session_catalog.flush
401
406
  true
402
407
  rescue StandardError
403
408
  false
@@ -848,21 +853,43 @@ module Kward
848
853
  keep_empty_paths = Array(keep_empty_path).filter_map do |path|
849
854
  File.expand_path(path) unless path.to_s.empty?
850
855
  end
851
- paths = Dir.glob(File.join(session_dir, "*.jsonl")).sort_by { |path| session_file_activity_time(path) }.reverse
852
- sessions = []
856
+ paths = Dir.glob(File.join(session_dir, "*.jsonl"))
857
+ @session_catalog.retain(paths)
858
+ return all_recent_sessions(paths, keep_empty_paths: keep_empty_paths) if limit.nil?
859
+
860
+ candidates = paths.map do |path|
861
+ info = cataloged_session_info(path)
862
+ [path, info, info&.modified_at || session_file_activity_time(path)]
863
+ end
864
+ candidates.sort_by! { |_path, _info, modified_at| modified_at }.reverse!
853
865
 
854
- paths.each do |path|
855
- if limit.nil? || sessions.length < limit
856
- info = session_info(path)
866
+ sessions = []
867
+ candidates.each do |path, cataloged_info, _modified_at|
868
+ if sessions.length < limit
869
+ info = cataloged_info || cache_session_info(path)
857
870
  next unless info
858
871
  next if delete_empty_unnamed_session_info(info, keep_empty_paths: keep_empty_paths)
859
872
 
860
873
  sessions << info
874
+ elsif cataloged_info
875
+ delete_empty_unnamed_session_info(cataloged_info, keep_empty_paths: keep_empty_paths)
861
876
  else
862
877
  delete_empty_unnamed_session_path(path, keep_empty_paths: keep_empty_paths)
863
878
  end
864
879
  end
865
880
  sessions
881
+ ensure
882
+ @session_catalog.flush
883
+ end
884
+
885
+ def all_recent_sessions(paths, keep_empty_paths:)
886
+ paths.filter_map do |path|
887
+ info = cataloged_session_info(path) || cache_session_info(path)
888
+ next unless info
889
+ next if delete_empty_unnamed_session_info(info, keep_empty_paths: keep_empty_paths)
890
+
891
+ info
892
+ end.sort_by(&:modified_at).reverse
866
893
  end
867
894
 
868
895
  def delete_empty_unnamed_session_info(info, keep_empty_paths: [])
@@ -870,6 +897,7 @@ module Kward
870
897
  return true if keep_empty_paths.include?(File.expand_path(info.path))
871
898
 
872
899
  File.delete(info.path)
900
+ @session_catalog.remove(info.path)
873
901
  true
874
902
  rescue StandardError
875
903
  false
@@ -894,6 +922,7 @@ module Kward
894
922
  return true if keep_empty_paths.include?(File.expand_path(path))
895
923
 
896
924
  File.delete(path)
925
+ @session_catalog.remove(path)
897
926
  true
898
927
  rescue StandardError
899
928
  false
@@ -943,6 +972,67 @@ module Kward
943
972
  end
944
973
  end
945
974
 
975
+ def cataloged_session_info(path)
976
+ summary = @session_catalog.fetch(path)
977
+ session_info_from_summary(path, summary) if summary
978
+ end
979
+
980
+ def cache_session_info(path)
981
+ fingerprint = @session_catalog.fingerprint(path)
982
+ info = session_info(path)
983
+ return nil unless info
984
+
985
+ current_fingerprint = @session_catalog.fingerprint(path)
986
+ if fingerprint == current_fingerprint
987
+ @session_catalog.write(path, session_info_summary(info), fingerprint: current_fingerprint)
988
+ end
989
+ info
990
+ rescue StandardError
991
+ nil
992
+ end
993
+
994
+ def session_info_summary(info)
995
+ {
996
+ "id" => info.id,
997
+ "cwd" => info.cwd,
998
+ "createdAt" => info.created_at.utc.iso8601(9),
999
+ "modifiedAt" => info.modified_at.utc.iso8601(9),
1000
+ "name" => info.name,
1001
+ "firstMessage" => info.first_message,
1002
+ "messageCount" => info.message_count,
1003
+ "provider" => info.provider,
1004
+ "model" => info.model,
1005
+ "reasoningEffort" => info.reasoning_effort,
1006
+ "parentId" => info.parent_id,
1007
+ "parentPath" => info.parent_path
1008
+ }
1009
+ end
1010
+
1011
+ def session_info_from_summary(path, summary)
1012
+ created_at = parse_time(summary["createdAt"])
1013
+ modified_at = parse_time(summary["modifiedAt"])
1014
+ return nil if summary["id"].to_s.empty? || !created_at || !modified_at
1015
+
1016
+ SessionInfo.new(
1017
+ id: summary["id"],
1018
+ path: path,
1019
+ cwd: summary["cwd"].to_s,
1020
+ created_at: created_at,
1021
+ modified_at: modified_at,
1022
+ name: summary["name"],
1023
+ first_message: summary["firstMessage"].to_s,
1024
+ message_count: summary["messageCount"].to_i,
1025
+ provider: summary["provider"],
1026
+ model: summary["model"],
1027
+ reasoning_effort: summary["reasoningEffort"],
1028
+ parent_id: summary["parentId"],
1029
+ parent_path: summary["parentPath"],
1030
+ depth: 0,
1031
+ is_last: true,
1032
+ ancestor_continues: []
1033
+ )
1034
+ end
1035
+
946
1036
  def session_info(path)
947
1037
  records = records_from_file(path)
948
1038
  header = session_header(records, path)
@@ -1,4 +1,5 @@
1
1
  require "pathname"
2
+ require_relative "trust_store"
2
3
 
3
4
  # Namespace for the Kward CLI agent runtime.
4
5
  module Kward
@@ -13,8 +14,9 @@ module Kward
13
14
  # @api public
14
15
  class Registry
15
16
  SkillSource = Struct.new(:root, :label, :scope, :precedence, keyword_init: true)
17
+ SkillCandidate = Struct.new(:path, :root, :label, :scope, :digest, keyword_init: true)
16
18
 
17
- def initialize(config_dir:, workspace_root:, project_skills_trusted:, skill_class:, max_file_bytes:, markdown_parser:, inside_directory:, warning_sink: nil)
19
+ def initialize(config_dir:, workspace_root:, project_skills_trusted:, skill_class:, max_file_bytes:, markdown_parser:, inside_directory:, warning_sink: nil, project_skill_paths: nil)
18
20
  @config_dir = config_dir
19
21
  @workspace_root = workspace_root
20
22
  @project_skills_trusted = project_skills_trusted
@@ -23,6 +25,32 @@ module Kward
23
25
  @markdown_parser = markdown_parser
24
26
  @inside_directory = inside_directory
25
27
  @warning_sink = warning_sink
28
+ @project_skill_paths = project_skill_paths&.map do |path|
29
+ File.realpath(path)
30
+ rescue SystemCallError
31
+ File.expand_path(path)
32
+ end
33
+ end
34
+
35
+ # Returns project skill files without activating their instructions.
36
+ #
37
+ # @return [Array<SkillCandidate>]
38
+ # @api public
39
+ def project_skill_candidates
40
+ skill_sources.select { |source| source.scope == :project }.flat_map do |source|
41
+ discover_source(source).filter_map do |path|
42
+ SkillCandidate.new(
43
+ path: path,
44
+ root: source.root,
45
+ label: source.label,
46
+ scope: source.scope,
47
+ digest: skill_digest(path)
48
+ )
49
+ rescue StandardError => e
50
+ emit_warning "Warning: skipping Kward skill #{path}: #{e.message}"
51
+ nil
52
+ end
53
+ end
26
54
  end
27
55
 
28
56
  # Returns discovered, validated skills in precedence order.
@@ -99,17 +127,39 @@ module Kward
99
127
 
100
128
  def scan_source(source)
101
129
  return [] unless Dir.exist?(source.root)
102
- if source.scope == :project && !@project_skills_trusted
130
+ if source.scope == :project && @project_skill_paths.nil? && !@project_skills_trusted
103
131
  emit_warning "Warning: skipping #{source.label} in #{source.root}: project skills are not trusted"
104
132
  return []
105
133
  end
106
134
 
135
+ paths = discover_source(source)
136
+ return paths unless source.scope == :project && @project_skill_paths
137
+
138
+ paths.select { |path| @project_skill_paths.include?(canonical_path(path)) }
139
+ end
140
+
141
+ def canonical_path(path)
142
+ File.realpath(path)
143
+ rescue SystemCallError
144
+ File.expand_path(path)
145
+ end
146
+
147
+ def discover_source(source)
107
148
  Dir.glob(File.join(source.root, "*", "SKILL.md")).sort
108
149
  rescue StandardError => e
109
150
  emit_warning "Warning: skipping #{source.label} in #{source.root}: #{e.message}"
110
151
  []
111
152
  end
112
153
 
154
+ def skill_digest(path)
155
+ TrustStore.digest_files(skill_files(path), root: @workspace_root)
156
+ end
157
+
158
+ def skill_files(path)
159
+ folder = File.dirname(path)
160
+ [path] + skill_resources(folder).map { |resource| File.join(folder, resource) }
161
+ end
162
+
113
163
  def skill_content(skill, content)
114
164
  lines = [
115
165
  %(<skill_content name="#{xml_escape(skill.name)}">),
@@ -0,0 +1,45 @@
1
+ require_relative "trust_store"
2
+
3
+ # Namespace for Agent Skills discovery and trust management.
4
+ module Kward
5
+ module Skills
6
+ # Resolves which discovered project skills need a user decision.
7
+ class TrustCoordinator
8
+ def initialize(workspace_root:, trust_store:)
9
+ @workspace_root = workspace_root
10
+ @trust_store = trust_store
11
+ end
12
+
13
+ def decision(candidate)
14
+ @trust_store.decision(
15
+ workspace_root: @workspace_root,
16
+ skill_path: candidate.path,
17
+ digest: candidate.digest
18
+ )
19
+ end
20
+
21
+ def pending(candidates)
22
+ candidates.reject { |candidate| decision(candidate) }
23
+ end
24
+
25
+ def allowed_paths(candidates)
26
+ candidates.filter_map { |candidate| candidate.path if decision(candidate) == "allow" }
27
+ end
28
+
29
+ def remove_workspace!
30
+ @trust_store.remove_workspace!(workspace_root: @workspace_root)
31
+ end
32
+
33
+ def record!(candidates, decision)
34
+ candidates.each do |candidate|
35
+ @trust_store.set_decision!(
36
+ workspace_root: @workspace_root,
37
+ skill_path: candidate.path,
38
+ digest: candidate.digest,
39
+ decision: decision
40
+ )
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,107 @@
1
+ require "digest"
2
+ require "json"
3
+ require "pathname"
4
+ require_relative "../path_guard"
5
+ require_relative "../private_file"
6
+
7
+ # Namespace for Agent Skills discovery and trust management.
8
+ module Kward
9
+ module Skills
10
+ # Persists workspace-scoped decisions for project skill snapshots.
11
+ class TrustStore
12
+ DECISIONS = %w[allow deny].freeze
13
+
14
+ attr_reader :path
15
+
16
+ def initialize(config_dir:)
17
+ @path = File.join(File.expand_path(config_dir), "trusted_project_skills.json")
18
+ end
19
+
20
+ def decision(workspace_root:, skill_path:, digest:)
21
+ record = skill_record(workspace_root: workspace_root, skill_path: skill_path)
22
+ return unless record
23
+ return unless record["digest"] == digest.to_s
24
+
25
+ decision = record["decision"]
26
+ decision if DECISIONS.include?(decision)
27
+ end
28
+
29
+ def set_decision!(workspace_root:, skill_path:, digest:, decision:)
30
+ decision = decision.to_s
31
+ raise ArgumentError, "invalid project skill trust decision: #{decision}" unless DECISIONS.include?(decision)
32
+
33
+ config = read_config
34
+ workspace = workspace_key(workspace_root)
35
+ skill = skill_key(workspace_root, skill_path)
36
+ config["workspaces"] ||= {}
37
+ config["workspaces"][workspace] ||= { "skills" => {} }
38
+ config["workspaces"][workspace]["skills"] ||= {}
39
+ config["workspaces"][workspace]["skills"][skill] = {
40
+ "digest" => digest.to_s,
41
+ "decision" => decision
42
+ }
43
+ PrivateFile.write_json(path, config)
44
+ end
45
+
46
+ def remove_workspace!(workspace_root:)
47
+ config = read_config
48
+ config.fetch("workspaces", {}).delete(workspace_key(workspace_root))
49
+ PrivateFile.write_json(path, config)
50
+ end
51
+
52
+ def remove_skill!(workspace_root:, skill_path:)
53
+ config = read_config
54
+ workspace = config.fetch("workspaces", {})[workspace_key(workspace_root)]
55
+ workspace&.fetch("skills", {})&.delete(skill_key(workspace_root, skill_path))
56
+ PrivateFile.write_json(path, config)
57
+ end
58
+
59
+ def self.digest_files(paths, root: nil)
60
+ root = File.realpath(root) if root
61
+ digest = Digest::SHA256.new
62
+ paths.map { |path| File.realpath(path) }.sort.each do |path|
63
+ raise ArgumentError, "file is outside digest root" if root && !PathGuard.inside?(path, root)
64
+
65
+ relative_path = root ? Pathname.new(path).relative_path_from(Pathname.new(root)).to_s : path
66
+ digest.update(relative_path)
67
+ digest.update("\0")
68
+ digest.update(File.binread(path))
69
+ digest.update("\0")
70
+ end
71
+ digest.hexdigest
72
+ end
73
+
74
+ private
75
+
76
+ def skill_record(workspace_root:, skill_path:)
77
+ workspace = read_config.fetch("workspaces", {})[workspace_key(workspace_root)]
78
+ workspace&.fetch("skills", {})&.[](skill_key(workspace_root, skill_path))
79
+ end
80
+
81
+ def read_config
82
+ return {} unless File.file?(path)
83
+
84
+ config = JSON.parse(File.read(path))
85
+ config.is_a?(Hash) ? config : {}
86
+ rescue JSON::ParserError, SystemCallError
87
+ {}
88
+ end
89
+
90
+ def workspace_key(workspace_root)
91
+ File.realpath(workspace_root)
92
+ rescue SystemCallError
93
+ File.expand_path(workspace_root)
94
+ end
95
+
96
+ def skill_key(workspace_root, skill_path)
97
+ workspace = workspace_key(workspace_root)
98
+ skill = File.realpath(skill_path)
99
+ raise ArgumentError, "project skill is outside workspace" unless PathGuard.inside?(skill, workspace)
100
+
101
+ Pathname.new(skill).relative_path_from(Pathname.new(workspace)).to_s
102
+ rescue SystemCallError
103
+ raise ArgumentError, "project skill path does not exist"
104
+ end
105
+ end
106
+ end
107
+ end