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
|
@@ -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
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
require_relative "../deep_copy"
|
|
2
|
+
require_relative "audit_log"
|
|
3
|
+
require_relative "catalog"
|
|
4
|
+
require_relative "decision"
|
|
5
|
+
require_relative "event"
|
|
6
|
+
require_relative "matcher"
|
|
7
|
+
|
|
8
|
+
# Namespace for the Kward CLI agent runtime.
|
|
9
|
+
module Kward
|
|
10
|
+
module Hooks
|
|
11
|
+
# Dispatches lifecycle events to registered hooks and combines decisions.
|
|
12
|
+
class Manager
|
|
13
|
+
Handler = Struct.new(:id, :event, :source, :order, :match, :callback, :failure_policy, keyword_init: true)
|
|
14
|
+
Result = Struct.new(:event, :decision, :decisions, :warnings, :messages, :payload, keyword_init: true) do
|
|
15
|
+
def allowed?
|
|
16
|
+
!decision.deny? && !decision.ask?
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def denied?
|
|
20
|
+
decision.deny?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def approval_required?
|
|
24
|
+
decision.ask?
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
attr_reader :handlers
|
|
29
|
+
attr_writer :on_result
|
|
30
|
+
|
|
31
|
+
def initialize(audit_log: AuditLog.new, on_result: nil)
|
|
32
|
+
@handlers = []
|
|
33
|
+
@audit_log = audit_log
|
|
34
|
+
@on_result = on_result
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def register(event, id: nil, source: nil, order: 100, match: nil, failure_policy: nil, &callback)
|
|
38
|
+
raise ArgumentError, "Hook requires an event name" if event.to_s.empty?
|
|
39
|
+
raise ArgumentError, "Hook requires a handler" unless callback
|
|
40
|
+
|
|
41
|
+
handler = Handler.new(
|
|
42
|
+
id: id || "#{source || "hook"}:#{event}:#{@handlers.length + 1}",
|
|
43
|
+
event: event.to_s,
|
|
44
|
+
source: source,
|
|
45
|
+
order: order.to_i,
|
|
46
|
+
match: Matcher.new(match),
|
|
47
|
+
callback: callback,
|
|
48
|
+
failure_policy: Catalog.failure_policy(event, failure_policy)
|
|
49
|
+
)
|
|
50
|
+
@handlers << handler
|
|
51
|
+
handler
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def empty?
|
|
55
|
+
@handlers.empty?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def run(event, context: nil)
|
|
59
|
+
event = normalize_event(event)
|
|
60
|
+
decisions = []
|
|
61
|
+
warnings = []
|
|
62
|
+
messages = []
|
|
63
|
+
payload = DeepCopy.dup(event.payload)
|
|
64
|
+
|
|
65
|
+
handlers_for(event).each do |handler|
|
|
66
|
+
decision = call_handler(handler, event, context)
|
|
67
|
+
decisions << decision
|
|
68
|
+
warnings << decision.message if decision.warning? && decision.message
|
|
69
|
+
messages << decision.message if decision.message && !decision.warning?
|
|
70
|
+
|
|
71
|
+
if decision.modify?
|
|
72
|
+
modifications = Catalog.filter_modifications(event.name, decision.payload)
|
|
73
|
+
payload = DeepCopy.merge(payload, modifications)
|
|
74
|
+
event = event_with_payload(event, payload)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
break if decision.deny?
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
final_decision = final_decision(decisions)
|
|
81
|
+
result = Result.new(
|
|
82
|
+
event: event,
|
|
83
|
+
decision: final_decision,
|
|
84
|
+
decisions: decisions,
|
|
85
|
+
warnings: warnings,
|
|
86
|
+
messages: messages,
|
|
87
|
+
payload: payload
|
|
88
|
+
)
|
|
89
|
+
@audit_log&.log_result(event: event, result: result)
|
|
90
|
+
@on_result&.call(event, result) unless decisions.empty?
|
|
91
|
+
result
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def normalize_event(event)
|
|
97
|
+
return event if event.is_a?(Event)
|
|
98
|
+
|
|
99
|
+
Event.new(**event)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def handlers_for(event)
|
|
103
|
+
@handlers
|
|
104
|
+
.select { |handler| handler.event == event.name && handler.match.match?(event) }
|
|
105
|
+
.sort_by { |handler| [handler.order, handler.id.to_s] }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def call_handler(handler, event, context)
|
|
109
|
+
started_at = @audit_log&.monotonic_now
|
|
110
|
+
value = if handler.callback.arity == 1
|
|
111
|
+
handler.callback.call(event)
|
|
112
|
+
else
|
|
113
|
+
handler.callback.call(event, context)
|
|
114
|
+
end
|
|
115
|
+
Decision.normalize(value).tap do |decision|
|
|
116
|
+
log_handler(event, handler, decision, started_at)
|
|
117
|
+
end
|
|
118
|
+
rescue StandardError => e
|
|
119
|
+
Catalog.failure_decision(
|
|
120
|
+
handler.failure_policy,
|
|
121
|
+
"Hook #{handler.id} failed: #{e.message}",
|
|
122
|
+
metadata: { hook_id: handler.id, source: handler.source }
|
|
123
|
+
).tap do |decision|
|
|
124
|
+
log_handler(event, handler, decision, started_at)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def log_handler(event, handler, decision, started_at)
|
|
129
|
+
@audit_log&.log_handler(
|
|
130
|
+
event: event,
|
|
131
|
+
handler: handler,
|
|
132
|
+
decision: decision,
|
|
133
|
+
duration_ms: started_at ? @audit_log.duration_ms(started_at) : nil
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def final_decision(decisions)
|
|
138
|
+
decisions.find(&:deny?) || decisions.find(&:ask?) || decisions.reverse.find(&:modify?) || decisions.find(&:warning?) || Decision.allow
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def event_with_payload(event, payload)
|
|
142
|
+
Event.new(
|
|
143
|
+
id: event.id,
|
|
144
|
+
name: event.name,
|
|
145
|
+
phase: event.phase,
|
|
146
|
+
timestamp: event.timestamp,
|
|
147
|
+
session: event.session,
|
|
148
|
+
turn: event.turn,
|
|
149
|
+
workspace: event.workspace,
|
|
150
|
+
frontend: event.frontend,
|
|
151
|
+
agent: event.agent,
|
|
152
|
+
payload: payload
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
module Hooks
|
|
4
|
+
# Matches lifecycle hook selectors against event payloads.
|
|
5
|
+
class Matcher
|
|
6
|
+
def initialize(match = nil)
|
|
7
|
+
@match = match || {}
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def match?(event)
|
|
11
|
+
return true if @match.empty?
|
|
12
|
+
|
|
13
|
+
@match.all? { |key, expected| matches_key?(event, key.to_s, expected) }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def matches_key?(event, key, expected)
|
|
19
|
+
case key
|
|
20
|
+
when "event", "name"
|
|
21
|
+
matches_value?(event.name, expected)
|
|
22
|
+
when "phase"
|
|
23
|
+
matches_value?(event.phase, expected)
|
|
24
|
+
when "tool", "tool_name"
|
|
25
|
+
matches_value?(payload_value(event, "tool_name") || payload_value(event, "tool"), expected)
|
|
26
|
+
when "mcp_server", "server"
|
|
27
|
+
matches_value?(payload_value(event, "server_name") || payload_value(event, "mcp_server"), expected)
|
|
28
|
+
when "mcp_tool", "remote_name"
|
|
29
|
+
matches_value?(payload_value(event, "remote_name") || payload_value(event, "mcp_tool"), expected)
|
|
30
|
+
when "operation"
|
|
31
|
+
matches_value?(payload_value(event, "operation"), expected)
|
|
32
|
+
when "frontend"
|
|
33
|
+
matches_value?(event.frontend[:name] || event.frontend["name"], expected)
|
|
34
|
+
when "provider"
|
|
35
|
+
matches_value?(event.agent[:provider] || event.agent["provider"], expected)
|
|
36
|
+
when "model"
|
|
37
|
+
matches_value?(event.agent[:model] || event.agent["model"], expected)
|
|
38
|
+
when "path", "paths"
|
|
39
|
+
matches_path?(payload_paths(event), expected)
|
|
40
|
+
when "command_regex"
|
|
41
|
+
value = payload_value(event, "command").to_s
|
|
42
|
+
Array(expected).any? { |pattern| Regexp.new(pattern.to_s).match?(value) }
|
|
43
|
+
else
|
|
44
|
+
matches_value?(payload_value(event, key), expected)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def payload_value(event, key)
|
|
49
|
+
event.payload[key.to_sym] || event.payload[key]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def payload_paths(event)
|
|
53
|
+
values = []
|
|
54
|
+
values << payload_value(event, "path")
|
|
55
|
+
values.concat(Array(payload_value(event, "paths")))
|
|
56
|
+
Array(payload_value(event, "files")).each do |file|
|
|
57
|
+
values << (file[:path] || file["path"]) if file.respond_to?(:[])
|
|
58
|
+
end
|
|
59
|
+
values.compact.map(&:to_s)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def matches_path?(paths, expected)
|
|
63
|
+
patterns = Array(expected).map(&:to_s)
|
|
64
|
+
paths.any? do |path|
|
|
65
|
+
patterns.any? { |pattern| File.fnmatch?(pattern, path, File::FNM_PATHNAME | File::FNM_EXTGLOB) }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def matches_value?(actual, expected)
|
|
70
|
+
expected_values = Array(expected).map(&:to_s)
|
|
71
|
+
expected_values.include?(actual.to_s)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
data/lib/kward/hooks.rb
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
require_relative "hooks/audit_log"
|
|
2
|
+
require_relative "hooks/catalog"
|
|
3
|
+
require_relative "hooks/decision"
|
|
4
|
+
require_relative "hooks/event"
|
|
5
|
+
require_relative "hooks/matcher"
|
|
6
|
+
require_relative "hooks/manager"
|
|
7
|
+
require_relative "hooks/command_handler"
|
|
8
|
+
require_relative "hooks/http_handler"
|
|
9
|
+
require_relative "hooks/config_loader"
|
|
10
|
+
|
|
11
|
+
# Namespace for the Kward CLI agent runtime.
|
|
12
|
+
module Kward
|
|
13
|
+
# Lifecycle hook primitives and dispatch helpers.
|
|
14
|
+
module Hooks
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require_relative "message_access"
|
|
2
|
+
require_relative "message_text"
|
|
2
3
|
|
|
3
4
|
# Namespace for the Kward CLI agent runtime.
|
|
4
5
|
module Kward
|
|
@@ -37,10 +38,10 @@ module Kward
|
|
|
37
38
|
end
|
|
38
39
|
|
|
39
40
|
def message_display_text(message)
|
|
40
|
-
|
|
41
|
-
return display_content.to_s unless display_content.nil?
|
|
41
|
+
return MessageText.full_text(message) unless MessageAccess.display_content(message).nil?
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
content = MessageAccess.content(message)
|
|
44
|
+
content.is_a?(Array) ? markdown_content(content) : MessageText.full_text(message)
|
|
44
45
|
end
|
|
45
46
|
|
|
46
47
|
def markdown_content(content)
|