kward 0.74.0 → 0.75.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +35 -0
- data/Gemfile.lock +2 -2
- data/README.md +4 -2
- data/Rakefile +18 -0
- data/doc/configuration.md +56 -3
- data/doc/extensibility.md +6 -19
- data/doc/getting-started.md +1 -1
- data/doc/lifecycle-hooks.md +424 -0
- data/doc/memory.md +1 -1
- data/doc/personas.md +2 -2
- data/doc/plugins.md +1 -1
- data/doc/releasing.md +7 -15
- data/doc/rpc.md +108 -16
- data/doc/session-management.md +9 -9
- data/doc/skills.md +199 -0
- data/doc/usage.md +7 -7
- data/lib/kward/agent.rb +72 -22
- data/lib/kward/auth/anthropic_oauth.rb +5 -87
- data/lib/kward/auth/oauth_helpers.rb +105 -0
- data/lib/kward/auth/openai_oauth.rb +4 -86
- data/lib/kward/cli/commands.rb +9 -2
- data/lib/kward/cli/compaction.rb +7 -0
- data/lib/kward/cli/git.rb +30 -3
- data/lib/kward/cli/hook_commands.rb +258 -0
- data/lib/kward/cli/memory_commands.rb +1 -5
- data/lib/kward/cli/plugins.rb +41 -1
- data/lib/kward/cli/prompt_interface.rb +32 -3
- data/lib/kward/cli/rendering.rb +1 -1
- data/lib/kward/cli/runtime_helpers.rb +44 -5
- data/lib/kward/cli/sessions.rb +13 -0
- data/lib/kward/cli/settings.rb +226 -152
- data/lib/kward/cli/slash_commands.rb +81 -42
- data/lib/kward/cli/tabs.rb +26 -6
- data/lib/kward/cli.rb +26 -3
- data/lib/kward/cli_transcript_formatter.rb +0 -7
- data/lib/kward/compaction/token_estimator.rb +112 -0
- data/lib/kward/compactor.rb +42 -116
- data/lib/kward/config_files.rb +117 -25
- data/lib/kward/deep_copy.rb +42 -0
- data/lib/kward/diff_view_mode.rb +36 -0
- data/lib/kward/events.rb +1 -0
- data/lib/kward/export_path.rb +2 -6
- data/lib/kward/frontmatter.rb +43 -0
- data/lib/kward/hooks/audit_log.rb +121 -0
- data/lib/kward/hooks/catalog.rb +110 -0
- data/lib/kward/hooks/command_handler.rb +66 -0
- data/lib/kward/hooks/config_loader.rb +104 -0
- data/lib/kward/hooks/decision.rb +102 -0
- data/lib/kward/hooks/event.rb +60 -0
- data/lib/kward/hooks/http_handler.rb +72 -0
- data/lib/kward/hooks/manager.rb +158 -0
- data/lib/kward/hooks/matcher.rb +75 -0
- data/lib/kward/hooks.rb +16 -0
- data/lib/kward/markdown_transcript.rb +4 -3
- data/lib/kward/mcp/client.rb +1 -1
- data/lib/kward/mcp/server_config.rb +7 -3
- data/lib/kward/mcp/stdio_transport.rb +7 -1
- data/lib/kward/memory/turn_context.rb +19 -0
- data/lib/kward/message_access.rb +2 -2
- data/lib/kward/message_text.rb +19 -0
- data/lib/kward/model/client.rb +13 -45
- data/lib/kward/model/context_usage.rb +10 -4
- data/lib/kward/model/copilot_models.rb +44 -0
- data/lib/kward/model/model_info.rb +24 -0
- data/lib/kward/model/payloads.rb +15 -17
- data/lib/kward/model/stream_parser.rb +178 -13
- data/lib/kward/pan/server.rb +51 -2
- data/lib/kward/path_guard.rb +13 -0
- data/lib/kward/plugin_registry.rb +86 -25
- data/lib/kward/prompt_interface/editor/controller.rb +60 -1
- data/lib/kward/prompt_interface/editor/renderer.rb +11 -0
- data/lib/kward/prompt_interface/editor/state.rb +5 -1
- data/lib/kward/prompt_interface/selection_prompt.rb +26 -6
- data/lib/kward/prompt_interface.rb +4 -1
- data/lib/kward/prompts/commands.rb +3 -3
- data/lib/kward/prompts.rb +7 -7
- data/lib/kward/rpc/mcp_status.rb +50 -0
- data/lib/kward/rpc/prompt_bridge.rb +38 -4
- data/lib/kward/rpc/runtime_payloads.rb +1 -1
- data/lib/kward/rpc/server.rb +94 -14
- data/lib/kward/rpc/session_manager.rb +258 -33
- data/lib/kward/rpc/session_metrics.rb +1 -1
- data/lib/kward/rpc/session_tree_rows.rb +13 -64
- data/lib/kward/rpc/tool_event_normalizer.rb +16 -2
- data/lib/kward/rpc/transcript_normalizer.rb +9 -10
- data/lib/kward/rpc/turn_context.rb +109 -0
- data/lib/kward/session_store.rb +6 -20
- data/lib/kward/session_tree_nodes.rb +98 -18
- data/lib/kward/session_tree_renderer.rb +6 -61
- data/lib/kward/skills/registry.rb +109 -17
- data/lib/kward/tools/mcp_tool.rb +4 -0
- data/lib/kward/tools/read_skill.rb +9 -4
- data/lib/kward/tools/registry.rb +266 -8
- data/lib/kward/tools/search/code.rb +4 -22
- data/lib/kward/tools/search/web.rb +28 -2
- data/lib/kward/update_check.rb +117 -0
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workers/manager.rb +43 -3
- data/lib/kward/workspace.rb +3 -1
- data/templates/default/fulldoc/html/css/kward.css +22 -0
- data/templates/default/fulldoc/html/js/kward.js +2 -0
- data/templates/default/kward_navigation.rb +4 -2
- data/templates/default/layout/html/setup.rb +2 -0
- metadata +25 -1
data/lib/kward/config_files.rb
CHANGED
|
@@ -2,9 +2,12 @@ require "digest"
|
|
|
2
2
|
require "fileutils"
|
|
3
3
|
require "json"
|
|
4
4
|
require "yaml"
|
|
5
|
+
require_relative "frontmatter"
|
|
5
6
|
require_relative "private_file"
|
|
7
|
+
require_relative "path_guard"
|
|
6
8
|
require_relative "ekwsh"
|
|
7
9
|
require_relative "editor_mode"
|
|
10
|
+
require_relative "diff_view_mode"
|
|
8
11
|
require_relative "prompts/templates"
|
|
9
12
|
require_relative "skills/registry"
|
|
10
13
|
|
|
@@ -41,7 +44,7 @@ module Kward
|
|
|
41
44
|
{
|
|
42
45
|
"key" => "kward",
|
|
43
46
|
"label" => "Kward",
|
|
44
|
-
"instruction" => "Your name is Kward, the grim Andruid - robotic keeper of the
|
|
47
|
+
"instruction" => "Your name is Kward, the grim Andruid - robotic keeper of the Forest of Code, protecting the nature of good engineering principles. Speak like an old druid, be suspicious of everyone, but with good intent."
|
|
45
48
|
}
|
|
46
49
|
],
|
|
47
50
|
"default" => "kward"
|
|
@@ -49,7 +52,7 @@ module Kward
|
|
|
49
52
|
OVERLAY_ALIGNMENTS = %w[left center right].freeze
|
|
50
53
|
OVERLAY_WIDTHS = %w[capped maximum].freeze
|
|
51
54
|
|
|
52
|
-
Skill = Struct.new(:name, :description, :folder, :path, keyword_init: true)
|
|
55
|
+
Skill = Struct.new(:name, :description, :folder, :path, :license, :compatibility, :metadata, :allowed_tools, keyword_init: true)
|
|
53
56
|
PromptTemplate = Struct.new(:command, :description, :argument_hint, :body, :path, keyword_init: true) do
|
|
54
57
|
def expand(arguments)
|
|
55
58
|
body.gsub("$ARGUMENTS", arguments.to_s)
|
|
@@ -92,6 +95,14 @@ module Kward
|
|
|
92
95
|
File.join(config_dir, "ekwsh.yml")
|
|
93
96
|
end
|
|
94
97
|
|
|
98
|
+
def workspace_hooks_path(root = Dir.pwd)
|
|
99
|
+
File.join(File.expand_path(root), ".kward", "hooks.json")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def trusted_workspace_hooks_path
|
|
103
|
+
File.join(config_dir, "trusted_workspace_hooks.json")
|
|
104
|
+
end
|
|
105
|
+
|
|
95
106
|
def default_config
|
|
96
107
|
{
|
|
97
108
|
"personas" => JSON.parse(JSON.generate(DEFAULT_PERSONAS)),
|
|
@@ -109,11 +120,21 @@ module Kward
|
|
|
109
120
|
"auto_close_pairs" => true,
|
|
110
121
|
"soft_wrap" => true,
|
|
111
122
|
"bar_cursor" => true,
|
|
112
|
-
"line_numbers" => "absolute"
|
|
123
|
+
"line_numbers" => "absolute",
|
|
124
|
+
"diff_view" => "auto"
|
|
125
|
+
},
|
|
126
|
+
"overlay" => DEFAULT_OVERLAY_SETTINGS.dup,
|
|
127
|
+
"web_search" => {
|
|
128
|
+
"enabled" => true,
|
|
129
|
+
"provider" => "auto",
|
|
130
|
+
"allow_model_providers" => false
|
|
113
131
|
},
|
|
114
132
|
"sessions" => {
|
|
115
133
|
"auto_resume" => false
|
|
116
134
|
},
|
|
135
|
+
"skills" => {
|
|
136
|
+
"trust_project" => false
|
|
137
|
+
},
|
|
117
138
|
"enforce_workspace_agents_file" => false,
|
|
118
139
|
"mcpServers" => {},
|
|
119
140
|
"tools" => {
|
|
@@ -140,6 +161,10 @@ module Kward
|
|
|
140
161
|
File.join(cache_dir, "openrouter_models.json")
|
|
141
162
|
end
|
|
142
163
|
|
|
164
|
+
def update_check_cache_path
|
|
165
|
+
File.join(cache_dir, "update_check.json")
|
|
166
|
+
end
|
|
167
|
+
|
|
143
168
|
def project_browser_state_path
|
|
144
169
|
File.join(cache_dir, "project_browser_state.json")
|
|
145
170
|
end
|
|
@@ -198,6 +223,72 @@ module Kward
|
|
|
198
223
|
PrivateFile.write_json(path, config)
|
|
199
224
|
end
|
|
200
225
|
|
|
226
|
+
def lifecycle_hooks_config(workspace_root = Dir.pwd)
|
|
227
|
+
config = read_config
|
|
228
|
+
workspace_config = read_trusted_workspace_hooks_config(workspace_root)
|
|
229
|
+
return config if workspace_config.empty?
|
|
230
|
+
|
|
231
|
+
merge_hooks_config(config, workspace_config)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def read_trusted_workspace_hooks_config(workspace_root = Dir.pwd)
|
|
235
|
+
path = workspace_hooks_path(workspace_root)
|
|
236
|
+
return {} unless workspace_hooks_trusted?(workspace_root)
|
|
237
|
+
|
|
238
|
+
read_config(path)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def workspace_hooks_trusted?(workspace_root = Dir.pwd)
|
|
242
|
+
path = workspace_hooks_path(workspace_root)
|
|
243
|
+
return false unless File.file?(path)
|
|
244
|
+
|
|
245
|
+
trusted = read_trusted_workspace_hooks
|
|
246
|
+
trusted[File.expand_path(path)] == workspace_hooks_digest(workspace_root)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def trust_workspace_hooks!(workspace_root = Dir.pwd)
|
|
250
|
+
path = workspace_hooks_path(workspace_root)
|
|
251
|
+
raise "No workspace hook config found: #{path}" unless File.file?(path)
|
|
252
|
+
|
|
253
|
+
trusted = read_trusted_workspace_hooks
|
|
254
|
+
trusted[File.expand_path(path)] = workspace_hooks_digest(workspace_root)
|
|
255
|
+
PrivateFile.write_json(trusted_workspace_hooks_path, trusted)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def untrust_workspace_hooks!(workspace_root = Dir.pwd)
|
|
259
|
+
path = File.expand_path(workspace_hooks_path(workspace_root))
|
|
260
|
+
trusted = read_trusted_workspace_hooks
|
|
261
|
+
trusted.delete(path)
|
|
262
|
+
PrivateFile.write_json(trusted_workspace_hooks_path, trusted)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def workspace_hooks_digest(workspace_root = Dir.pwd)
|
|
266
|
+
Digest::SHA256.file(workspace_hooks_path(workspace_root)).hexdigest
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def read_trusted_workspace_hooks
|
|
270
|
+
config = read_config(trusted_workspace_hooks_path)
|
|
271
|
+
config.is_a?(Hash) ? config : {}
|
|
272
|
+
rescue ConfigError
|
|
273
|
+
{}
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def merge_hooks_config(config, workspace_config)
|
|
277
|
+
merged = JSON.parse(JSON.generate(config || {}))
|
|
278
|
+
merged["hooks"] = merge_hook_maps(merged["hooks"], workspace_config["hooks"] || workspace_config[:hooks])
|
|
279
|
+
merged
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def merge_hook_maps(left, right)
|
|
283
|
+
left = left.is_a?(Hash) ? left : {}
|
|
284
|
+
right = right.is_a?(Hash) ? right : {}
|
|
285
|
+
merged = JSON.parse(JSON.generate(left))
|
|
286
|
+
right.each do |event, entries|
|
|
287
|
+
merged[event.to_s] = Array(merged[event.to_s]) + Array(entries)
|
|
288
|
+
end
|
|
289
|
+
merged
|
|
290
|
+
end
|
|
291
|
+
|
|
201
292
|
def read_ekwsh_config(path = ekwsh_config_path)
|
|
202
293
|
path = File.expand_path(path)
|
|
203
294
|
return normalize_ekwsh_config(nil) unless File.exist?(path)
|
|
@@ -364,12 +455,24 @@ module Kward
|
|
|
364
455
|
EditorMode.normalize_line_numbers(editor["line_numbers"])
|
|
365
456
|
end
|
|
366
457
|
|
|
458
|
+
# Returns the integrated diff viewer display mode.
|
|
459
|
+
def diff_view(config = read_config)
|
|
460
|
+
editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
|
|
461
|
+
DiffViewMode.normalize(editor["diff_view"])
|
|
462
|
+
end
|
|
463
|
+
|
|
367
464
|
# Returns whether file tools must stay inside the active workspace root.
|
|
368
465
|
def workspace_guardrails_enabled?(config = read_config)
|
|
369
466
|
tools = config["tools"].is_a?(Hash) ? config["tools"] : {}
|
|
370
467
|
tools["workspace_guardrails"] != false
|
|
371
468
|
end
|
|
372
469
|
|
|
470
|
+
# Returns whether project-level Agent Skills should be loaded from the workspace.
|
|
471
|
+
def project_skills_trusted?(config = read_config)
|
|
472
|
+
skills = config["skills"].is_a?(Hash) ? config["skills"] : {}
|
|
473
|
+
skills["trust_project"] == true
|
|
474
|
+
end
|
|
475
|
+
|
|
373
476
|
# Returns whether new frontends should resume the last active session automatically.
|
|
374
477
|
def session_auto_resume_enabled?(config = read_config)
|
|
375
478
|
sessions = config["sessions"].is_a?(Hash) ? config["sessions"] : {}
|
|
@@ -399,7 +502,7 @@ module Kward
|
|
|
399
502
|
raise "Overlay settings must be an object" unless values.is_a?(Hash)
|
|
400
503
|
|
|
401
504
|
config = read_config
|
|
402
|
-
overlay = config
|
|
505
|
+
overlay = overlay_settings(config)
|
|
403
506
|
values.each do |key, value|
|
|
404
507
|
key = key.to_s
|
|
405
508
|
value = value.to_s
|
|
@@ -674,8 +777,8 @@ module Kward
|
|
|
674
777
|
# Lists configured skills discovered under the config directory.
|
|
675
778
|
#
|
|
676
779
|
# @return [Array<Skill>] skill metadata available to the model
|
|
677
|
-
def skills
|
|
678
|
-
skills_registry.skills
|
|
780
|
+
def skills(workspace_root: Dir.pwd)
|
|
781
|
+
skills_registry(workspace_root: workspace_root).skills
|
|
679
782
|
end
|
|
680
783
|
|
|
681
784
|
# @return [String] trusted user plugin directory
|
|
@@ -712,16 +815,18 @@ module Kward
|
|
|
712
815
|
# @param name [String] configured skill name
|
|
713
816
|
# @param relative_path [String, nil] path inside the skill directory
|
|
714
817
|
# @return [String] file contents or an error string
|
|
715
|
-
def read_skill_file(name, relative_path = nil)
|
|
716
|
-
skills_registry.read_skill_file(name, relative_path)
|
|
818
|
+
def read_skill_file(name, relative_path = nil, workspace_root: Dir.pwd)
|
|
819
|
+
skills_registry(workspace_root: workspace_root).read_skill_file(name, relative_path)
|
|
717
820
|
end
|
|
718
821
|
|
|
719
|
-
def skills_registry
|
|
822
|
+
def skills_registry(workspace_root: Dir.pwd)
|
|
720
823
|
Skills::Registry.new(
|
|
721
824
|
config_dir: config_dir,
|
|
825
|
+
workspace_root: workspace_root,
|
|
826
|
+
project_skills_trusted: project_skills_trusted?,
|
|
722
827
|
skill_class: Skill,
|
|
723
828
|
max_file_bytes: MAX_SKILL_FILE_BYTES,
|
|
724
|
-
markdown_parser:
|
|
829
|
+
markdown_parser: ->(path) { Frontmatter.markdown_parts(path, lenient: true) },
|
|
725
830
|
inside_directory: method(:inside_directory?)
|
|
726
831
|
)
|
|
727
832
|
end
|
|
@@ -730,25 +835,12 @@ module Kward
|
|
|
730
835
|
Prompts::Templates.new(
|
|
731
836
|
config_dir: config_dir,
|
|
732
837
|
template_class: PromptTemplate,
|
|
733
|
-
markdown_parser:
|
|
838
|
+
markdown_parser: ->(path) { Frontmatter.markdown_parts(path) }
|
|
734
839
|
)
|
|
735
840
|
end
|
|
736
841
|
|
|
737
|
-
def markdown_parts(path)
|
|
738
|
-
content = File.read(path)
|
|
739
|
-
return [{}, content] unless content.start_with?("---\n", "---\r\n")
|
|
740
|
-
|
|
741
|
-
_opening, rest = content.split(/\A---\r?\n/, 2)
|
|
742
|
-
yaml_text, body = rest.to_s.split(/\r?\n---\r?\n/, 2)
|
|
743
|
-
raise "missing frontmatter closing delimiter" if body.nil?
|
|
744
|
-
|
|
745
|
-
data = yaml_text.to_s.empty? ? {} : YAML.safe_load(yaml_text, permitted_classes: [], aliases: false)
|
|
746
|
-
frontmatter = data.is_a?(Hash) ? data.transform_keys(&:to_s) : {}
|
|
747
|
-
[frontmatter, body]
|
|
748
|
-
end
|
|
749
|
-
|
|
750
842
|
def inside_directory?(path, base)
|
|
751
|
-
|
|
843
|
+
PathGuard.inside?(path, base)
|
|
752
844
|
end
|
|
753
845
|
|
|
754
846
|
def presence(value)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Small recursive copy/freeze helpers for plain Hash/Array payload objects.
|
|
4
|
+
module DeepCopy
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def dup(value)
|
|
8
|
+
case value
|
|
9
|
+
when Hash
|
|
10
|
+
value.each_with_object({}) { |(key, item), result| result[key] = dup(item) }
|
|
11
|
+
when Array
|
|
12
|
+
value.map { |item| dup(item) }
|
|
13
|
+
else
|
|
14
|
+
value.dup
|
|
15
|
+
end
|
|
16
|
+
rescue TypeError
|
|
17
|
+
value
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def freeze(value)
|
|
21
|
+
case value
|
|
22
|
+
when Hash
|
|
23
|
+
value.each_value { |item| freeze(item) }
|
|
24
|
+
when Array
|
|
25
|
+
value.each { |item| freeze(item) }
|
|
26
|
+
end
|
|
27
|
+
value.freeze
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def merge(left, right)
|
|
31
|
+
left = dup(left)
|
|
32
|
+
right.each do |key, value|
|
|
33
|
+
left[key] = if left[key].is_a?(Hash) && value.is_a?(Hash)
|
|
34
|
+
merge(left[key], value)
|
|
35
|
+
else
|
|
36
|
+
dup(value)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
left
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Normalizes and resolves the integrated diff viewer display mode.
|
|
4
|
+
module DiffViewMode
|
|
5
|
+
AUTO = "auto"
|
|
6
|
+
UNIFIED = "unified"
|
|
7
|
+
SIDE_BY_SIDE = "side_by_side"
|
|
8
|
+
MODES = [AUTO, UNIFIED, SIDE_BY_SIDE].freeze
|
|
9
|
+
SIDE_BY_SIDE_MIN_WIDTH = 120
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def normalize(value)
|
|
14
|
+
text = value.to_s.downcase.tr("-", "_")
|
|
15
|
+
MODES.include?(text) ? text : AUTO
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def label(value)
|
|
19
|
+
case normalize(value)
|
|
20
|
+
when SIDE_BY_SIDE
|
|
21
|
+
"side-by-side"
|
|
22
|
+
when UNIFIED
|
|
23
|
+
"unified"
|
|
24
|
+
else
|
|
25
|
+
"auto"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def resolve(value, terminal_width: nil)
|
|
30
|
+
mode = normalize(value)
|
|
31
|
+
return mode unless mode == AUTO
|
|
32
|
+
|
|
33
|
+
terminal_width.to_i >= SIDE_BY_SIDE_MIN_WIDTH ? SIDE_BY_SIDE : UNIFIED
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/kward/events.rb
CHANGED
|
@@ -9,6 +9,7 @@ module Kward
|
|
|
9
9
|
Steering = Struct.new(:input, :created_at, keyword_init: true)
|
|
10
10
|
SteeringApplied = Struct.new(:count, keyword_init: true)
|
|
11
11
|
ToolCall = Struct.new(:tool_call, keyword_init: true)
|
|
12
|
+
ToolUpdate = Struct.new(:tool_call, :content, :elapsed_ms, keyword_init: true)
|
|
12
13
|
ToolResult = Struct.new(:tool_call, :content, keyword_init: true)
|
|
13
14
|
Answer = Struct.new(:content, keyword_init: true)
|
|
14
15
|
end
|
data/lib/kward/export_path.rb
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "pathname"
|
|
2
|
+
require_relative "path_guard"
|
|
2
3
|
|
|
3
4
|
# Namespace for the Kward CLI agent runtime.
|
|
4
5
|
module Kward
|
|
@@ -11,7 +12,7 @@ module Kward
|
|
|
11
12
|
resolved = File.expand_path(explicit, workspace_root)
|
|
12
13
|
allowed_roots = [workspace_root, session_dir].compact.map { |root| Pathname.new(root).expand_path }
|
|
13
14
|
expanded = Pathname.new(resolved).expand_path
|
|
14
|
-
unless allowed_roots.any? { |root| inside?(expanded, root) }
|
|
15
|
+
unless allowed_roots.any? { |root| PathGuard.inside?(expanded, root) }
|
|
15
16
|
raise ArgumentError, "export path outside workspace or session directory: #{path}"
|
|
16
17
|
end
|
|
17
18
|
|
|
@@ -21,10 +22,5 @@ module Kward
|
|
|
21
22
|
|
|
22
23
|
resolved
|
|
23
24
|
end
|
|
24
|
-
|
|
25
|
-
def self.inside?(path, root)
|
|
26
|
-
path.to_s == root.to_s || path.to_s.start_with?("#{root}/")
|
|
27
|
-
end
|
|
28
|
-
private_class_method :inside?
|
|
29
25
|
end
|
|
30
26
|
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
# Namespace for the Kward CLI agent runtime.
|
|
4
|
+
module Kward
|
|
5
|
+
# Markdown frontmatter parsing shared by prompt templates and skills.
|
|
6
|
+
module Frontmatter
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def markdown_parts(path, lenient: false)
|
|
10
|
+
content = File.read(path)
|
|
11
|
+
return [{}, content] unless content.start_with?("---\n", "---\r\n")
|
|
12
|
+
|
|
13
|
+
_opening, rest = content.split(/\A---\r?\n/, 2)
|
|
14
|
+
yaml_text, body = rest.to_s.split(/\r?\n---\r?\n/, 2)
|
|
15
|
+
raise "missing frontmatter closing delimiter" if body.nil?
|
|
16
|
+
|
|
17
|
+
data = yaml_text.to_s.empty? ? {} : load_frontmatter(yaml_text, lenient: lenient)
|
|
18
|
+
frontmatter = data.is_a?(Hash) ? data.transform_keys(&:to_s) : {}
|
|
19
|
+
[frontmatter, body]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def load_frontmatter(yaml_text, lenient: false)
|
|
23
|
+
YAML.safe_load(yaml_text, permitted_classes: [], aliases: false)
|
|
24
|
+
rescue Psych::SyntaxError
|
|
25
|
+
raise unless lenient
|
|
26
|
+
|
|
27
|
+
lenient_frontmatter(yaml_text)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def lenient_frontmatter(yaml_text)
|
|
31
|
+
yaml_text.each_line.each_with_object({}) do |line, result|
|
|
32
|
+
match = line.chomp.match(/\A([A-Za-z0-9_-]+):\s*(.*)\z/)
|
|
33
|
+
next unless match
|
|
34
|
+
|
|
35
|
+
key = match[1]
|
|
36
|
+
value = match[2].strip
|
|
37
|
+
next if value.empty?
|
|
38
|
+
|
|
39
|
+
result[key] = value.delete_prefix('"').delete_suffix('"')
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "json"
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "../config_files"
|
|
5
|
+
require_relative "../rpc/redactor"
|
|
6
|
+
|
|
7
|
+
# Namespace for the Kward CLI agent runtime.
|
|
8
|
+
module Kward
|
|
9
|
+
module Hooks
|
|
10
|
+
# Append-only JSONL audit log for lifecycle hook activity.
|
|
11
|
+
class AuditLog
|
|
12
|
+
DEFAULT_MAX_BYTES = 10 * 1024 * 1024
|
|
13
|
+
|
|
14
|
+
def initialize(path: nil, config_path: ConfigFiles.config_path, max_bytes: DEFAULT_MAX_BYTES, clock: Time, monotonic_clock: Process, error_output: $stderr)
|
|
15
|
+
@path = path
|
|
16
|
+
@config_path = config_path
|
|
17
|
+
@max_bytes = max_bytes.to_i.positive? ? max_bytes.to_i : DEFAULT_MAX_BYTES
|
|
18
|
+
@clock = clock
|
|
19
|
+
@monotonic_clock = monotonic_clock
|
|
20
|
+
@error_output = error_output
|
|
21
|
+
@mutex = Mutex.new
|
|
22
|
+
@warned = false
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def monotonic_now
|
|
26
|
+
@monotonic_clock.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def duration_ms(started_at)
|
|
30
|
+
((monotonic_now - started_at.to_f) * 1000).round(1)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def log_handler(event:, handler:, decision:, duration_ms: nil)
|
|
34
|
+
write_record(
|
|
35
|
+
"kind" => "handler",
|
|
36
|
+
"event_id" => event.id,
|
|
37
|
+
"event" => event.name,
|
|
38
|
+
"phase" => event.phase,
|
|
39
|
+
"hook_id" => handler.id,
|
|
40
|
+
"source" => handler.source,
|
|
41
|
+
"decision" => decision.decision,
|
|
42
|
+
"message" => redact_string(decision.message),
|
|
43
|
+
"duration_ms" => duration_ms,
|
|
44
|
+
"payload_keys" => safe_keys(event.payload),
|
|
45
|
+
"modified_keys" => decision.modify? ? safe_keys(decision.payload) : []
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def log_result(event:, result:)
|
|
50
|
+
write_record(
|
|
51
|
+
"kind" => "result",
|
|
52
|
+
"event_id" => event.id,
|
|
53
|
+
"event" => event.name,
|
|
54
|
+
"phase" => event.phase,
|
|
55
|
+
"decision" => result.decision.decision,
|
|
56
|
+
"message" => redact_string(result.decision.message),
|
|
57
|
+
"handler_count" => result.decisions.length,
|
|
58
|
+
"warnings" => result.warnings.map { |warning| redact_string(warning) },
|
|
59
|
+
"messages" => result.messages.map { |message| redact_string(message) },
|
|
60
|
+
"payload_keys" => safe_keys(result.payload)
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def write_record(payload)
|
|
67
|
+
@mutex.synchronize do
|
|
68
|
+
path = current_path
|
|
69
|
+
FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
|
|
70
|
+
File.open(path, File::WRONLY | File::CREAT | File::APPEND, 0o600) do |file|
|
|
71
|
+
file.write(JSON.generate(payload.compact.merge("timestamp" => @clock.now.utc.iso8601(3))))
|
|
72
|
+
file.write("\n")
|
|
73
|
+
end
|
|
74
|
+
File.chmod(0o600, path)
|
|
75
|
+
end
|
|
76
|
+
true
|
|
77
|
+
rescue StandardError => e
|
|
78
|
+
warn_once(e)
|
|
79
|
+
false
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def current_path
|
|
83
|
+
explicit_path = @path || File.join(File.dirname(File.expand_path(@config_path)), "logs", "hooks.jsonl")
|
|
84
|
+
return explicit_path if writable_log_path?(explicit_path)
|
|
85
|
+
|
|
86
|
+
index = 1
|
|
87
|
+
loop do
|
|
88
|
+
path = explicit_path.sub(/\.jsonl\z/, "-#{index}.jsonl")
|
|
89
|
+
return path if writable_log_path?(path)
|
|
90
|
+
|
|
91
|
+
index += 1
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def writable_log_path?(path)
|
|
96
|
+
!File.exist?(path) || File.size(path) < @max_bytes
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def safe_keys(value)
|
|
100
|
+
return [] unless value.is_a?(Hash)
|
|
101
|
+
|
|
102
|
+
value.keys.map(&:to_s).sort
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def redact_string(value)
|
|
106
|
+
return nil if value.nil?
|
|
107
|
+
|
|
108
|
+
RPC::Redactor.redact_string(value.to_s)[0, 500]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def warn_once(error)
|
|
112
|
+
return if @warned
|
|
113
|
+
|
|
114
|
+
@warned = true
|
|
115
|
+
@error_output&.puts("Warning: hook audit logging failed: #{error.message}")
|
|
116
|
+
rescue StandardError
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
module Hooks
|
|
4
|
+
# Known lifecycle hook events and their safety defaults.
|
|
5
|
+
module Catalog
|
|
6
|
+
DEFAULT_FAILURE_POLICY = "warn"
|
|
7
|
+
VALID_FAILURE_POLICIES = %w[allow warn deny ask].freeze
|
|
8
|
+
|
|
9
|
+
EventDefinition = Struct.new(:name, :failure_policy, :modifiable_fields, keyword_init: true)
|
|
10
|
+
|
|
11
|
+
DEFINITIONS = [
|
|
12
|
+
EventDefinition.new(name: "turn_start", failure_policy: "warn", modifiable_fields: %w[input display_input]),
|
|
13
|
+
EventDefinition.new(name: "turn_context_build_before", failure_policy: "warn"),
|
|
14
|
+
EventDefinition.new(name: "turn_context_build_after", failure_policy: "warn"),
|
|
15
|
+
EventDefinition.new(name: "model_request_before", failure_policy: "warn", modifiable_fields: %w[messages tools provider model reasoning]),
|
|
16
|
+
EventDefinition.new(name: "turn_model_request_before", failure_policy: "warn"),
|
|
17
|
+
EventDefinition.new(name: "model_response_after_parse", failure_policy: "warn"),
|
|
18
|
+
EventDefinition.new(name: "turn_model_response_complete", failure_policy: "warn"),
|
|
19
|
+
EventDefinition.new(name: "turn_end", failure_policy: "warn"),
|
|
20
|
+
EventDefinition.new(name: "session_create", failure_policy: "warn"),
|
|
21
|
+
EventDefinition.new(name: "session_resume", failure_policy: "warn"),
|
|
22
|
+
EventDefinition.new(name: "session_clone", failure_policy: "warn"),
|
|
23
|
+
EventDefinition.new(name: "session_fork", failure_policy: "warn"),
|
|
24
|
+
EventDefinition.new(name: "session_rename", failure_policy: "warn"),
|
|
25
|
+
EventDefinition.new(name: "session_export_before", failure_policy: "deny"),
|
|
26
|
+
EventDefinition.new(name: "session_export_after", failure_policy: "warn"),
|
|
27
|
+
EventDefinition.new(name: "session_compact_before", failure_policy: "deny"),
|
|
28
|
+
EventDefinition.new(name: "session_compact_after", failure_policy: "warn"),
|
|
29
|
+
EventDefinition.new(name: "tool_output_compact_before", failure_policy: "warn"),
|
|
30
|
+
EventDefinition.new(name: "tool_output_compact_after", failure_policy: "warn"),
|
|
31
|
+
EventDefinition.new(name: "tool_call_before", failure_policy: "deny", modifiable_fields: %w[arguments]),
|
|
32
|
+
EventDefinition.new(name: "tool_call_after", failure_policy: "warn"),
|
|
33
|
+
EventDefinition.new(name: "tool_call_error", failure_policy: "warn"),
|
|
34
|
+
EventDefinition.new(name: "mcp_tool_before", failure_policy: "deny"),
|
|
35
|
+
EventDefinition.new(name: "mcp_tool_after", failure_policy: "warn"),
|
|
36
|
+
EventDefinition.new(name: "mcp_tool_error", failure_policy: "warn"),
|
|
37
|
+
EventDefinition.new(name: "shell_command_before", failure_policy: "deny", modifiable_fields: %w[command timeout_seconds]),
|
|
38
|
+
EventDefinition.new(name: "shell_command_after", failure_policy: "warn"),
|
|
39
|
+
EventDefinition.new(name: "file_change_before", failure_policy: "deny"),
|
|
40
|
+
EventDefinition.new(name: "file_change_after", failure_policy: "warn"),
|
|
41
|
+
EventDefinition.new(name: "git_status_after", failure_policy: "warn"),
|
|
42
|
+
EventDefinition.new(name: "git_diff_before", failure_policy: "deny"),
|
|
43
|
+
EventDefinition.new(name: "git_diff_after", failure_policy: "warn"),
|
|
44
|
+
EventDefinition.new(name: "git_stage_before", failure_policy: "deny"),
|
|
45
|
+
EventDefinition.new(name: "git_stage_after", failure_policy: "warn"),
|
|
46
|
+
EventDefinition.new(name: "git_commit_before", failure_policy: "deny"),
|
|
47
|
+
EventDefinition.new(name: "git_commit_after", failure_policy: "warn"),
|
|
48
|
+
EventDefinition.new(name: "worker_job_create", failure_policy: "warn"),
|
|
49
|
+
EventDefinition.new(name: "worker_job_start_before", failure_policy: "warn"),
|
|
50
|
+
EventDefinition.new(name: "worker_job_start_after", failure_policy: "warn"),
|
|
51
|
+
EventDefinition.new(name: "worker_job_ready_for_review", failure_policy: "warn"),
|
|
52
|
+
EventDefinition.new(name: "worker_job_failed", failure_policy: "warn")
|
|
53
|
+
].each_with_object({}) { |definition, result| result[definition.name] = definition }.freeze
|
|
54
|
+
|
|
55
|
+
module_function
|
|
56
|
+
|
|
57
|
+
def event_names
|
|
58
|
+
DEFINITIONS.keys.sort
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def definition(event_name)
|
|
62
|
+
DEFINITIONS[event_name.to_s]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def known?(event_name)
|
|
66
|
+
DEFINITIONS.key?(event_name.to_s)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def failure_policy(event_name, explicit_policy = nil)
|
|
70
|
+
normalize_failure_policy(explicit_policy || definition(event_name)&.failure_policy || DEFAULT_FAILURE_POLICY)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def normalize_failure_policy(policy)
|
|
74
|
+
value = policy.to_s
|
|
75
|
+
return value if VALID_FAILURE_POLICIES.include?(value)
|
|
76
|
+
|
|
77
|
+
raise ArgumentError, "Unknown hook failure policy: #{policy}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def modifiable_fields(event_name)
|
|
81
|
+
Array(definition(event_name)&.modifiable_fields)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def filter_modifications(event_name, payload)
|
|
85
|
+
definition = definition(event_name)
|
|
86
|
+
return payload unless definition
|
|
87
|
+
|
|
88
|
+
fields = Array(definition.modifiable_fields)
|
|
89
|
+
return {} if fields.empty?
|
|
90
|
+
|
|
91
|
+
payload.each_with_object({}) do |(key, value), result|
|
|
92
|
+
result[key] = value if fields.include?(key.to_s)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def failure_decision(policy, message, metadata: nil)
|
|
97
|
+
case normalize_failure_policy(policy)
|
|
98
|
+
when "allow"
|
|
99
|
+
Decision.allow(message, metadata: metadata)
|
|
100
|
+
when "warn"
|
|
101
|
+
Decision.warn(message, metadata: metadata)
|
|
102
|
+
when "deny"
|
|
103
|
+
Decision.deny(message, metadata: metadata)
|
|
104
|
+
when "ask"
|
|
105
|
+
Decision.ask(message, metadata: metadata)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|