kward 0.73.1 → 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 +51 -0
- data/Gemfile.lock +2 -2
- data/README.md +4 -2
- data/Rakefile +18 -0
- data/doc/configuration.md +70 -4
- data/doc/extensibility.md +6 -19
- data/doc/getting-started.md +1 -1
- data/doc/lifecycle-hooks.md +424 -0
- data/doc/mcp.md +72 -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 -15
- data/doc/session-management.md +9 -9
- data/doc/skills.md +199 -0
- data/doc/usage.md +14 -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 +12 -2
- data/lib/kward/cli/compaction.rb +7 -0
- data/lib/kward/cli/doctor.rb +18 -10
- data/lib/kward/cli/git.rb +30 -3
- data/lib/kward/cli/hook_commands.rb +258 -0
- data/lib/kward/cli/interactive_turn.rb +9 -3
- 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 +32 -15
- data/lib/kward/cli.rb +64 -4
- 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 +152 -27
- 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 +56 -0
- data/lib/kward/mcp/server_config.rb +59 -0
- data/lib/kward/mcp/stdio_transport.rb +112 -0
- 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 +124 -4
- data/lib/kward/prompt_interface/editor/modes/emacs.rb +4 -0
- data/lib/kward/prompt_interface/editor/modes/modern.rb +10 -4
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +448 -20
- data/lib/kward/prompt_interface/editor/renderer.rb +27 -0
- data/lib/kward/prompt_interface/editor/search.rb +70 -13
- data/lib/kward/prompt_interface/editor/state.rb +72 -13
- data/lib/kward/prompt_interface/editor/vibe_state.rb +12 -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 -13
- 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 +122 -0
- data/lib/kward/tools/read_skill.rb +9 -4
- data/lib/kward/tools/registry.rb +297 -9
- 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 +30 -1
|
@@ -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
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "open3"
|
|
3
|
+
require "shellwords"
|
|
4
|
+
require "timeout"
|
|
5
|
+
require_relative "catalog"
|
|
6
|
+
require_relative "decision"
|
|
7
|
+
|
|
8
|
+
# Namespace for the Kward CLI agent runtime.
|
|
9
|
+
module Kward
|
|
10
|
+
module Hooks
|
|
11
|
+
# Executes an external command hook using JSON over stdin/stdout.
|
|
12
|
+
class CommandHandler
|
|
13
|
+
DEFAULT_TIMEOUT_SECONDS = 10
|
|
14
|
+
|
|
15
|
+
def initialize(command:, timeout_seconds: DEFAULT_TIMEOUT_SECONDS, env: nil, failure_policy: Catalog::DEFAULT_FAILURE_POLICY)
|
|
16
|
+
@command = command.to_s
|
|
17
|
+
@timeout_seconds = positive_timeout(timeout_seconds)
|
|
18
|
+
@env = stringify_hash(env || {})
|
|
19
|
+
@failure_policy = Catalog.normalize_failure_policy(failure_policy)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call(event, _context = nil)
|
|
23
|
+
stdout, stderr, status = run_command(JSON.dump(event.to_h))
|
|
24
|
+
return failure_decision("Command hook failed: #{stderr.strip.empty? ? "exit #{status.exitstatus}" : stderr.strip}") unless status.success?
|
|
25
|
+
|
|
26
|
+
parse_decision(stdout)
|
|
27
|
+
rescue Timeout::Error
|
|
28
|
+
failure_decision("Command hook timed out after #{@timeout_seconds}s")
|
|
29
|
+
rescue StandardError => e
|
|
30
|
+
failure_decision("Command hook failed: #{e.message}")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def run_command(input)
|
|
36
|
+
Timeout.timeout(@timeout_seconds) do
|
|
37
|
+
Open3.capture3(@env, *@command.shellsplit, stdin_data: input)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def parse_decision(stdout)
|
|
42
|
+
text = stdout.to_s.strip
|
|
43
|
+
return Decision.allow if text.empty?
|
|
44
|
+
|
|
45
|
+
Decision.normalize(JSON.parse(text))
|
|
46
|
+
rescue JSON::ParserError => e
|
|
47
|
+
failure_decision("Command hook returned invalid JSON: #{e.message}")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def failure_decision(message)
|
|
51
|
+
Catalog.failure_decision(@failure_policy, message)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def positive_timeout(value)
|
|
55
|
+
seconds = value.to_i
|
|
56
|
+
seconds.positive? ? seconds : DEFAULT_TIMEOUT_SECONDS
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def stringify_hash(value)
|
|
60
|
+
value.each_with_object({}) do |(key, item), result|
|
|
61
|
+
result[key.to_s] = item.to_s
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
require_relative "catalog"
|
|
2
|
+
require_relative "command_handler"
|
|
3
|
+
require_relative "http_handler"
|
|
4
|
+
require_relative "manager"
|
|
5
|
+
|
|
6
|
+
# Namespace for the Kward CLI agent runtime.
|
|
7
|
+
module Kward
|
|
8
|
+
module Hooks
|
|
9
|
+
# Builds hook managers from Kward configuration hashes.
|
|
10
|
+
class ConfigLoader
|
|
11
|
+
def initialize(config)
|
|
12
|
+
@config = config || {}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def manager
|
|
16
|
+
Manager.new.tap do |manager|
|
|
17
|
+
hooks_config.each do |event, entries|
|
|
18
|
+
Array(entries).each_with_index do |entry, index|
|
|
19
|
+
register_entry(manager, event.to_s, entry, index)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def hooks_config
|
|
28
|
+
value = @config["hooks"] || @config[:hooks]
|
|
29
|
+
value.is_a?(Hash) ? value : {}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def register_entry(manager, event, entry, index)
|
|
33
|
+
entry = normalize_entry(entry)
|
|
34
|
+
return if truthy?(entry["disabled"])
|
|
35
|
+
|
|
36
|
+
handler = handler_for(entry)
|
|
37
|
+
callback = truthy?(entry["async"]) ? async_callback(handler) : handler
|
|
38
|
+
manager.register(
|
|
39
|
+
event,
|
|
40
|
+
id: entry["id"] || "config:#{event}:#{index + 1}",
|
|
41
|
+
source: "config",
|
|
42
|
+
order: entry.fetch("order", 100),
|
|
43
|
+
match: entry["match"],
|
|
44
|
+
failure_policy: entry["failure_policy"]
|
|
45
|
+
) { |hook_event, context| callback.call(hook_event, context) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def normalize_entry(entry)
|
|
49
|
+
case entry
|
|
50
|
+
when String
|
|
51
|
+
{ "type" => "command", "command" => entry }
|
|
52
|
+
when Hash
|
|
53
|
+
entry.transform_keys(&:to_s)
|
|
54
|
+
else
|
|
55
|
+
raise ArgumentError, "Invalid hook config entry: #{entry.inspect}"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def handler_for(entry)
|
|
60
|
+
type = (entry["type"] || "command").to_s
|
|
61
|
+
case type
|
|
62
|
+
when "command"
|
|
63
|
+
CommandHandler.new(
|
|
64
|
+
command: required(entry, "command"),
|
|
65
|
+
timeout_seconds: entry["timeout_seconds"],
|
|
66
|
+
env: entry["env"],
|
|
67
|
+
failure_policy: entry["failure_policy"] || Catalog::DEFAULT_FAILURE_POLICY
|
|
68
|
+
)
|
|
69
|
+
when "http"
|
|
70
|
+
HttpHandler.new(
|
|
71
|
+
url: required(entry, "url"),
|
|
72
|
+
timeout_seconds: entry["timeout_seconds"],
|
|
73
|
+
headers: entry["headers"],
|
|
74
|
+
failure_policy: entry["failure_policy"] || Catalog::DEFAULT_FAILURE_POLICY
|
|
75
|
+
)
|
|
76
|
+
else
|
|
77
|
+
raise ArgumentError, "Unsupported hook type: #{type}"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def async_callback(handler)
|
|
82
|
+
lambda do |hook_event, context|
|
|
83
|
+
Thread.new do
|
|
84
|
+
handler.call(hook_event, context)
|
|
85
|
+
rescue StandardError
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
Decision.allow("Async hook scheduled")
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def required(entry, key)
|
|
93
|
+
value = entry[key]
|
|
94
|
+
raise ArgumentError, "Hook config requires #{key}" if value.to_s.empty?
|
|
95
|
+
|
|
96
|
+
value
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def truthy?(value)
|
|
100
|
+
value == true || value.to_s == "true"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
module Hooks
|
|
4
|
+
# Normalized result returned by lifecycle hooks.
|
|
5
|
+
class Decision
|
|
6
|
+
VALID_DECISIONS = %w[allow deny ask modify warn retry defer].freeze
|
|
7
|
+
|
|
8
|
+
attr_reader :decision, :message, :payload, :metadata
|
|
9
|
+
|
|
10
|
+
def initialize(decision:, message: nil, payload: nil, metadata: nil)
|
|
11
|
+
@decision = decision.to_s
|
|
12
|
+
raise ArgumentError, "Unknown hook decision: #{@decision}" unless VALID_DECISIONS.include?(@decision)
|
|
13
|
+
|
|
14
|
+
@message = message&.to_s
|
|
15
|
+
@payload = payload || {}
|
|
16
|
+
@metadata = metadata || {}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.allow(message = nil, metadata: nil)
|
|
20
|
+
new(decision: "allow", message: message, metadata: metadata)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.deny(message = nil, metadata: nil)
|
|
24
|
+
new(decision: "deny", message: message, metadata: metadata)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.ask(message = nil, metadata: nil)
|
|
28
|
+
new(decision: "ask", message: message, metadata: metadata)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.modify(payload = nil, message: nil, metadata: nil, **payload_keywords)
|
|
32
|
+
payload = payload_keywords unless payload_keywords.empty?
|
|
33
|
+
new(decision: "modify", message: message, payload: payload || {}, metadata: metadata)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.warn(message = nil, metadata: nil)
|
|
37
|
+
new(decision: "warn", message: message, metadata: metadata)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.retry(message = nil, payload: nil, metadata: nil)
|
|
41
|
+
new(decision: "retry", message: message, payload: payload, metadata: metadata)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.defer(message = nil, payload: nil, metadata: nil)
|
|
45
|
+
new(decision: "defer", message: message, payload: payload, metadata: metadata)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.normalize(value)
|
|
49
|
+
case value
|
|
50
|
+
when nil
|
|
51
|
+
allow
|
|
52
|
+
when Decision
|
|
53
|
+
value
|
|
54
|
+
when String, Symbol
|
|
55
|
+
new(decision: value)
|
|
56
|
+
when Hash
|
|
57
|
+
new(
|
|
58
|
+
decision: value[:decision] || value["decision"] || "allow",
|
|
59
|
+
message: value[:message] || value["message"],
|
|
60
|
+
payload: value[:payload] || value["payload"],
|
|
61
|
+
metadata: value[:metadata] || value["metadata"]
|
|
62
|
+
)
|
|
63
|
+
else
|
|
64
|
+
raise ArgumentError, "Invalid hook decision: #{value.inspect}"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def allow?
|
|
69
|
+
decision == "allow"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def deny?
|
|
73
|
+
decision == "deny"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def ask?
|
|
77
|
+
decision == "ask"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def modify?
|
|
81
|
+
decision == "modify"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def warning?
|
|
85
|
+
decision == "warn"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def blocking?
|
|
89
|
+
deny? || ask?
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def to_h
|
|
93
|
+
{
|
|
94
|
+
decision: decision,
|
|
95
|
+
message: message,
|
|
96
|
+
payload: payload,
|
|
97
|
+
metadata: metadata
|
|
98
|
+
}
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
require "time"
|
|
3
|
+
require_relative "../deep_copy"
|
|
4
|
+
|
|
5
|
+
# Namespace for the Kward CLI agent runtime.
|
|
6
|
+
module Kward
|
|
7
|
+
module Hooks
|
|
8
|
+
# Immutable lifecycle event delivered to hook handlers.
|
|
9
|
+
class Event
|
|
10
|
+
attr_reader :id, :name, :phase, :timestamp, :session, :turn, :workspace, :frontend, :agent, :payload
|
|
11
|
+
|
|
12
|
+
def initialize(name:, phase: nil, timestamp: Time.now.utc, session: nil, turn: nil, workspace: nil, frontend: nil, agent: nil, payload: nil, id: nil)
|
|
13
|
+
@id = id || "hookevt_#{SecureRandom.hex(12)}"
|
|
14
|
+
@name = name.to_s
|
|
15
|
+
@phase = phase&.to_s || inferred_phase(@name)
|
|
16
|
+
@timestamp = timestamp.is_a?(Time) ? timestamp.utc : Time.parse(timestamp.to_s).utc
|
|
17
|
+
@session = frozen_copy(session || {})
|
|
18
|
+
@turn = frozen_copy(turn || {})
|
|
19
|
+
@workspace = frozen_copy(workspace || {})
|
|
20
|
+
@frontend = frozen_copy(frontend || {})
|
|
21
|
+
@agent = frozen_copy(agent || {})
|
|
22
|
+
@payload = frozen_copy(payload || {})
|
|
23
|
+
freeze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def [](key)
|
|
27
|
+
payload[key] || payload[key.to_s]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def to_h
|
|
31
|
+
{
|
|
32
|
+
id: id,
|
|
33
|
+
name: name,
|
|
34
|
+
phase: phase,
|
|
35
|
+
timestamp: timestamp.iso8601,
|
|
36
|
+
session: session,
|
|
37
|
+
turn: turn,
|
|
38
|
+
workspace: workspace,
|
|
39
|
+
frontend: frontend,
|
|
40
|
+
agent: agent,
|
|
41
|
+
payload: payload
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def inferred_phase(name)
|
|
48
|
+
return "before" if name.end_with?("_before") || name.include?("before_")
|
|
49
|
+
return "after" if name.end_with?("_after") || name.include?("after_")
|
|
50
|
+
return "error" if name.end_with?("_error") || name == "error"
|
|
51
|
+
|
|
52
|
+
"during"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def frozen_copy(value)
|
|
56
|
+
DeepCopy.freeze(DeepCopy.dup(value))
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "uri"
|
|
4
|
+
require_relative "catalog"
|
|
5
|
+
require_relative "decision"
|
|
6
|
+
|
|
7
|
+
# Namespace for the Kward CLI agent runtime.
|
|
8
|
+
module Kward
|
|
9
|
+
module Hooks
|
|
10
|
+
# Executes an HTTP lifecycle hook using JSON request/response bodies.
|
|
11
|
+
class HttpHandler
|
|
12
|
+
DEFAULT_TIMEOUT_SECONDS = 10
|
|
13
|
+
|
|
14
|
+
def initialize(url:, timeout_seconds: DEFAULT_TIMEOUT_SECONDS, headers: nil, failure_policy: Catalog::DEFAULT_FAILURE_POLICY, http_client: Net::HTTP)
|
|
15
|
+
@uri = URI.parse(url.to_s)
|
|
16
|
+
raise ArgumentError, "HTTP hook requires http or https URL" unless %w[http https].include?(@uri.scheme)
|
|
17
|
+
|
|
18
|
+
@timeout_seconds = positive_timeout(timeout_seconds)
|
|
19
|
+
@headers = stringify_hash(headers || {})
|
|
20
|
+
@failure_policy = Catalog.normalize_failure_policy(failure_policy)
|
|
21
|
+
@http_client = http_client
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call(event, _context = nil)
|
|
25
|
+
response = post(JSON.dump(event.to_h))
|
|
26
|
+
return failure_decision("HTTP hook failed: #{response.code} #{response.message}") unless response.is_a?(Net::HTTPSuccess)
|
|
27
|
+
|
|
28
|
+
parse_decision(response.body)
|
|
29
|
+
rescue StandardError => e
|
|
30
|
+
failure_decision("HTTP hook failed: #{e.message}")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def post(body)
|
|
36
|
+
request = Net::HTTP::Post.new(@uri)
|
|
37
|
+
request["Content-Type"] = "application/json"
|
|
38
|
+
request["Accept"] = "application/json"
|
|
39
|
+
@headers.each { |key, value| request[key] = value }
|
|
40
|
+
request.body = body
|
|
41
|
+
|
|
42
|
+
@http_client.start(@uri.hostname, @uri.port, use_ssl: @uri.scheme == "https", open_timeout: @timeout_seconds, read_timeout: @timeout_seconds) do |http|
|
|
43
|
+
http.request(request)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def parse_decision(body)
|
|
48
|
+
text = body.to_s.strip
|
|
49
|
+
return Decision.allow if text.empty?
|
|
50
|
+
|
|
51
|
+
Decision.normalize(JSON.parse(text))
|
|
52
|
+
rescue JSON::ParserError => e
|
|
53
|
+
failure_decision("HTTP hook returned invalid JSON: #{e.message}")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def failure_decision(message)
|
|
57
|
+
Catalog.failure_decision(@failure_policy, message)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def positive_timeout(value)
|
|
61
|
+
seconds = value.to_i
|
|
62
|
+
seconds.positive? ? seconds : DEFAULT_TIMEOUT_SECONDS
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def stringify_hash(value)
|
|
66
|
+
value.each_with_object({}) do |(key, item), result|
|
|
67
|
+
result[key.to_s] = item.to_s
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|