kward 0.77.0 → 0.79.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/.github/workflows/ci.yml +7 -2
- data/.github/workflows/pages.yml +1 -1
- data/CHANGELOG.md +50 -0
- data/Gemfile.lock +2 -2
- data/README.md +5 -2
- data/doc/agent-tools.md +1 -1
- data/doc/code-search.md +9 -6
- data/doc/configuration.md +106 -1
- data/doc/extensibility.md +2 -0
- data/doc/getting-started.md +1 -1
- data/doc/local-models.md +130 -0
- data/doc/permissions.md +180 -0
- data/doc/plugins.md +58 -0
- data/doc/releasing.md +1 -1
- data/doc/rpc.md +73 -1
- data/doc/sandboxing.md +120 -0
- data/doc/security.md +15 -5
- data/doc/skills.md +10 -0
- data/doc/tabs.md +4 -3
- data/doc/web-search.md +4 -2
- data/doc/workspace-tools.md +3 -3
- data/kward.gemspec +1 -1
- data/lib/kward/auth/anthropic_oauth.rb +2 -2
- data/lib/kward/auth/github_oauth.rb +3 -3
- data/lib/kward/auth/oauth_helpers.rb +4 -2
- data/lib/kward/auth/openai_oauth.rb +2 -1
- data/lib/kward/cli/doctor.rb +21 -0
- data/lib/kward/cli/plugins.rb +22 -0
- data/lib/kward/cli/rendering.rb +7 -1
- data/lib/kward/cli/runtime_helpers.rb +15 -1
- data/lib/kward/cli/settings.rb +66 -4
- data/lib/kward/cli/slash_commands.rb +109 -1
- data/lib/kward/cli/tabs.rb +140 -34
- data/lib/kward/cli.rb +31 -10
- data/lib/kward/config_files.rb +90 -1
- data/lib/kward/conversation.rb +14 -1
- data/lib/kward/hooks/http_handler.rb +2 -1
- data/lib/kward/http.rb +18 -0
- data/lib/kward/local_command_runner.rb +2 -2
- data/lib/kward/model/client.rb +173 -15
- data/lib/kward/model/model_info.rb +11 -1
- data/lib/kward/model/payloads.rb +7 -1
- data/lib/kward/model/stream_parser.rb +58 -26
- data/lib/kward/openrouter_model_cache.rb +2 -1
- data/lib/kward/pan/index.html.erb +50 -0
- data/lib/kward/pan/server.rb +49 -2
- data/lib/kward/permissions/policy.rb +171 -0
- data/lib/kward/plugin_registry.rb +54 -2
- data/lib/kward/prompt_interface/approval_prompt.rb +62 -0
- data/lib/kward/prompt_interface/editor/controller.rb +36 -3
- data/lib/kward/prompt_interface/question_prompt.rb +12 -3
- data/lib/kward/prompt_interface.rb +20 -0
- data/lib/kward/prompts/commands.rb +2 -0
- data/lib/kward/prompts.rb +16 -5
- data/lib/kward/rpc/plugin_chat_manager.rb +302 -0
- data/lib/kward/rpc/server.rb +76 -3
- data/lib/kward/rpc/session_manager.rb +43 -8
- data/lib/kward/rpc/transcript_normalizer.rb +14 -5
- data/lib/kward/sandbox/capabilities.rb +39 -0
- data/lib/kward/sandbox/command_runner.rb +28 -0
- data/lib/kward/sandbox/environment.rb +24 -0
- data/lib/kward/sandbox/linux_bubblewrap_runner.rb +71 -0
- data/lib/kward/sandbox/macos_seatbelt_runner.rb +96 -0
- data/lib/kward/sandbox/passthrough_runner.rb +13 -0
- data/lib/kward/sandbox/policy.rb +74 -0
- data/lib/kward/sandbox/runner_factory.rb +55 -0
- data/lib/kward/sandbox/unavailable_runner.rb +21 -0
- data/lib/kward/sandbox.rb +9 -0
- data/lib/kward/session_store.rb +26 -0
- data/lib/kward/skills/capture.rb +144 -0
- data/lib/kward/starter_pack_installer.rb +3 -1
- data/lib/kward/tab_driver.rb +87 -0
- data/lib/kward/tab_store.rb +74 -12
- data/lib/kward/tools/code_search.rb +8 -2
- data/lib/kward/tools/fetch_content.rb +4 -2
- data/lib/kward/tools/fetch_raw.rb +3 -1
- data/lib/kward/tools/registry.rb +56 -8
- data/lib/kward/tools/search/code.rb +46 -12
- data/lib/kward/tools/search/web.rb +60 -17
- data/lib/kward/tools/search/web_fetch.rb +206 -38
- data/lib/kward/tools/web_search.rb +3 -1
- data/lib/kward/update_check.rb +2 -1
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workspace.rb +18 -3
- data/lib/kward/workspace_factory.rb +17 -0
- data/templates/default/fulldoc/html/js/kward.js +1 -0
- data/templates/default/kward_navigation.rb +5 -2
- data/templates/default/layout/html/layout.erb +2 -0
- data/templates/default/layout/html/setup.rb +2 -0
- metadata +22 -2
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
require_relative "../local_command_runner"
|
|
2
|
+
|
|
3
|
+
# Namespace for operating-system command sandboxing.
|
|
4
|
+
module Kward
|
|
5
|
+
module Sandbox
|
|
6
|
+
# Base command runner. Platform runners provide an argv that is then executed
|
|
7
|
+
# through LocalCommandRunner, preserving Kward's timeout and cancellation behavior.
|
|
8
|
+
class CommandRunner
|
|
9
|
+
def initialize(policy:, capabilities:)
|
|
10
|
+
@policy = policy
|
|
11
|
+
@capabilities = capabilities
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
attr_reader :policy, :capabilities
|
|
15
|
+
|
|
16
|
+
def run(command, cwd:, timeout_seconds:, max_output_bytes:, cancellation: nil, &block)
|
|
17
|
+
LocalCommandRunner.new(
|
|
18
|
+
timeout_seconds: timeout_seconds,
|
|
19
|
+
max_output_bytes: max_output_bytes
|
|
20
|
+
).run(*command_argv(command, cwd: cwd), cwd: cwd, cancellation: cancellation, &block)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def command_argv(command, cwd:)
|
|
24
|
+
raise NotImplementedError, "#{self.class} must implement #command_argv"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Namespace for operating-system command sandboxing.
|
|
2
|
+
module Kward
|
|
3
|
+
module Sandbox
|
|
4
|
+
# Builds a minimal environment for sandboxed command workers. In particular,
|
|
5
|
+
# credentials and language-runtime injection variables are not inherited.
|
|
6
|
+
module Environment
|
|
7
|
+
SAFE_VARIABLES = %w[PATH LANG LC_ALL LC_CTYPE TERM COLORTERM NO_COLOR].freeze
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def command_worker(temporary_root, source: ENV)
|
|
12
|
+
SAFE_VARIABLES.each_with_object({}) do |name, environment|
|
|
13
|
+
value = source[name].to_s
|
|
14
|
+
environment[name] = value unless value.empty?
|
|
15
|
+
end.merge(
|
|
16
|
+
"HOME" => temporary_root,
|
|
17
|
+
"TMPDIR" => temporary_root,
|
|
18
|
+
"TMP" => temporary_root,
|
|
19
|
+
"TEMP" => temporary_root
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "tmpdir"
|
|
3
|
+
require_relative "command_runner"
|
|
4
|
+
require_relative "environment"
|
|
5
|
+
|
|
6
|
+
# Namespace for operating-system command sandboxing.
|
|
7
|
+
module Kward
|
|
8
|
+
module Sandbox
|
|
9
|
+
# Runs a command in a Bubblewrap mount namespace on Linux.
|
|
10
|
+
class LinuxBubblewrapRunner < CommandRunner
|
|
11
|
+
def self.executable
|
|
12
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).filter_map do |directory|
|
|
13
|
+
path = File.expand_path("bwrap", directory)
|
|
14
|
+
path if File.file?(path) && File.executable?(path)
|
|
15
|
+
end.first
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.capabilities(platform: RUBY_PLATFORM, executable: self.executable)
|
|
19
|
+
available = platform.to_s.include?("linux") && !executable.to_s.empty?
|
|
20
|
+
return Capabilities.new(available: true, filesystem_enforced: true, child_network_enforced: true, backend: "linux_bubblewrap") if available
|
|
21
|
+
|
|
22
|
+
Capabilities.new(
|
|
23
|
+
available: false,
|
|
24
|
+
filesystem_enforced: false,
|
|
25
|
+
child_network_enforced: false,
|
|
26
|
+
backend: "linux_bubblewrap",
|
|
27
|
+
reason: "Bubblewrap is unavailable"
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def initialize(policy:, executable: self.class.executable, capabilities: self.class.capabilities(executable: executable))
|
|
32
|
+
raise ArgumentError, "Linux Bubblewrap is unavailable: #{capabilities.reason}" unless capabilities.available?
|
|
33
|
+
|
|
34
|
+
super(policy:, capabilities:)
|
|
35
|
+
@executable = executable
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def run(command, cwd:, timeout_seconds:, max_output_bytes:, cancellation: nil, &block)
|
|
39
|
+
temporary_root = Dir.mktmpdir("kward-sandbox")
|
|
40
|
+
environment = Environment.command_worker(temporary_root)
|
|
41
|
+
|
|
42
|
+
LocalCommandRunner.new(
|
|
43
|
+
timeout_seconds: timeout_seconds,
|
|
44
|
+
max_output_bytes: max_output_bytes
|
|
45
|
+
).run(
|
|
46
|
+
*command_argv(command, cwd: cwd, temporary_root: temporary_root),
|
|
47
|
+
env: environment,
|
|
48
|
+
cwd: cwd,
|
|
49
|
+
cancellation: cancellation,
|
|
50
|
+
unsetenv_others: true,
|
|
51
|
+
&block
|
|
52
|
+
)
|
|
53
|
+
ensure
|
|
54
|
+
FileUtils.remove_entry(temporary_root) if temporary_root && File.exist?(temporary_root)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def command_argv(command, cwd:, temporary_root: nil)
|
|
58
|
+
temporary_root ||= Dir.tmpdir
|
|
59
|
+
workspace_root = policy.workspace_root
|
|
60
|
+
writable_roots = policy.command_writable_roots + [File.realpath(temporary_root)]
|
|
61
|
+
argv = [@executable, "--die-with-parent", "--new-session", "--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc"]
|
|
62
|
+
argv << "--unshare-net" unless policy.child_network_allowed?
|
|
63
|
+
argv.concat(["--bind", workspace_root, workspace_root]) if policy.workspace_write?
|
|
64
|
+
writable_roots.reject { |path| path == workspace_root }.each { |path| argv.concat(["--bind", path, path]) }
|
|
65
|
+
argv.concat(["--ro-bind", File.join(workspace_root, ".git"), File.join(workspace_root, ".git")]) if policy.protect_git_metadata? && File.exist?(File.join(workspace_root, ".git"))
|
|
66
|
+
argv.concat(["--setenv", "TMPDIR", temporary_root, "--setenv", "TMP", temporary_root, "--setenv", "TEMP", temporary_root])
|
|
67
|
+
argv.concat(["--chdir", cwd.to_s, "--", "/bin/sh", "-lc", command.to_s])
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "tmpdir"
|
|
3
|
+
require_relative "command_runner"
|
|
4
|
+
require_relative "environment"
|
|
5
|
+
|
|
6
|
+
# Namespace for operating-system command sandboxing.
|
|
7
|
+
module Kward
|
|
8
|
+
module Sandbox
|
|
9
|
+
# Runs a command under macOS Seatbelt. This backend deliberately constrains
|
|
10
|
+
# writes and child networking first; host-process and plugin access remain
|
|
11
|
+
# outside the command worker boundary.
|
|
12
|
+
class MacOSSeatbeltRunner < CommandRunner
|
|
13
|
+
EXECUTABLE = "/usr/bin/sandbox-exec".freeze
|
|
14
|
+
PROTECTED_READ_DIRECTORIES = %w[.aws .gnupg .kward .ssh .config/gcloud .config/gh].freeze
|
|
15
|
+
|
|
16
|
+
def self.capabilities(platform: RUBY_PLATFORM)
|
|
17
|
+
available = platform.to_s.include?("darwin") && File.executable?(EXECUTABLE)
|
|
18
|
+
return Capabilities.new(available: true, filesystem_enforced: true, child_network_enforced: true, backend: "macos_seatbelt") if available
|
|
19
|
+
|
|
20
|
+
Capabilities.new(
|
|
21
|
+
available: false,
|
|
22
|
+
filesystem_enforced: false,
|
|
23
|
+
child_network_enforced: false,
|
|
24
|
+
backend: "macos_seatbelt",
|
|
25
|
+
reason: "#{EXECUTABLE} is unavailable"
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initialize(policy:, capabilities: self.class.capabilities)
|
|
30
|
+
raise ArgumentError, "macOS Seatbelt is unavailable: #{capabilities.reason}" unless capabilities.available?
|
|
31
|
+
|
|
32
|
+
super
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def run(command, cwd:, timeout_seconds:, max_output_bytes:, cancellation: nil, &block)
|
|
36
|
+
temporary_root = Dir.mktmpdir("kward-sandbox")
|
|
37
|
+
environment = Environment.command_worker(temporary_root)
|
|
38
|
+
|
|
39
|
+
LocalCommandRunner.new(
|
|
40
|
+
timeout_seconds: timeout_seconds,
|
|
41
|
+
max_output_bytes: max_output_bytes
|
|
42
|
+
).run(
|
|
43
|
+
*command_argv(command, cwd: cwd, temporary_root: temporary_root),
|
|
44
|
+
env: environment,
|
|
45
|
+
cwd: cwd,
|
|
46
|
+
cancellation: cancellation,
|
|
47
|
+
unsetenv_others: true,
|
|
48
|
+
&block
|
|
49
|
+
)
|
|
50
|
+
ensure
|
|
51
|
+
FileUtils.remove_entry(temporary_root) if temporary_root && File.exist?(temporary_root)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def command_argv(command, cwd:, temporary_root: nil)
|
|
55
|
+
temporary_root ||= Dir.tmpdir
|
|
56
|
+
[EXECUTABLE, "-p", profile(temporary_root), "/bin/zsh", "-lc", command.to_s]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def profile(temporary_root)
|
|
62
|
+
writable_roots = policy.command_writable_roots + [File.realpath(temporary_root)]
|
|
63
|
+
rules = [
|
|
64
|
+
"(version 1)",
|
|
65
|
+
"(deny default)",
|
|
66
|
+
"(allow process*)",
|
|
67
|
+
"(allow signal)",
|
|
68
|
+
"(allow file-read*)",
|
|
69
|
+
"(allow file-write-data (literal \"/dev/null\"))",
|
|
70
|
+
"(allow sysctl-read)",
|
|
71
|
+
"(allow mach-lookup)",
|
|
72
|
+
"(allow ipc-posix-shm*)",
|
|
73
|
+
"(allow user-preference-read)",
|
|
74
|
+
"(allow pseudo-tty)"
|
|
75
|
+
]
|
|
76
|
+
rules << "(allow network*)" if policy.child_network_allowed?
|
|
77
|
+
rules.concat(writable_roots.map { |path| "(allow file-write* (subpath #{seatbelt_string(path)}))" })
|
|
78
|
+
rules.concat(protected_read_roots.map { |path| "(deny file-read* (subpath #{seatbelt_string(path)}))" })
|
|
79
|
+
rules << "(deny file-write* (subpath #{seatbelt_string(File.join(policy.workspace_root, ".git"))}))" if policy.protect_git_metadata?
|
|
80
|
+
rules.join("\n")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def protected_read_roots
|
|
84
|
+
home = ENV.fetch("HOME", Dir.home)
|
|
85
|
+
PROTECTED_READ_DIRECTORIES.map do |path|
|
|
86
|
+
root = File.join(home, path)
|
|
87
|
+
File.exist?(root) ? File.realpath(root) : root
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def seatbelt_string(value)
|
|
92
|
+
%Q{"#{value.to_s.gsub(/([\\"])/, '\\\\1')}"}
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
require_relative "command_runner"
|
|
2
|
+
|
|
3
|
+
# Namespace for operating-system command sandboxing.
|
|
4
|
+
module Kward
|
|
5
|
+
module Sandbox
|
|
6
|
+
# Preserves existing LocalCommandRunner behavior when sandboxing is off.
|
|
7
|
+
class PassthroughRunner < CommandRunner
|
|
8
|
+
def command_argv(command, cwd:)
|
|
9
|
+
[command.to_s]
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
require "pathname"
|
|
2
|
+
|
|
3
|
+
# Namespace for operating-system command sandboxing.
|
|
4
|
+
module Kward
|
|
5
|
+
module Sandbox
|
|
6
|
+
# Immutable, user-configured restrictions for a command worker.
|
|
7
|
+
class Policy
|
|
8
|
+
MODES = %w[off read_only workspace_write].freeze
|
|
9
|
+
NETWORK_MODES = %w[deny allow].freeze
|
|
10
|
+
|
|
11
|
+
attr_reader :mode, :network, :workspace_root, :writable_roots
|
|
12
|
+
|
|
13
|
+
def initialize(mode: "off", network: "deny", workspace_root:, writable_roots: [], protect_git_metadata: true)
|
|
14
|
+
@mode = normalize_mode(mode)
|
|
15
|
+
@network = normalize_network(network)
|
|
16
|
+
@workspace_root = canonical_path(workspace_root)
|
|
17
|
+
@writable_roots = normalize_writable_roots(writable_roots)
|
|
18
|
+
@protect_git_metadata = protect_git_metadata != false
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def enabled?
|
|
22
|
+
mode != "off"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def read_only?
|
|
26
|
+
mode == "read_only"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def workspace_write?
|
|
30
|
+
mode == "workspace_write"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def protect_git_metadata?
|
|
34
|
+
@protect_git_metadata
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def child_network_allowed?
|
|
38
|
+
network == "allow"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def command_writable_roots
|
|
42
|
+
return [] unless workspace_write?
|
|
43
|
+
|
|
44
|
+
([workspace_root] + writable_roots).uniq
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def normalize_mode(value)
|
|
50
|
+
mode = value.to_s
|
|
51
|
+
return mode if MODES.include?(mode)
|
|
52
|
+
|
|
53
|
+
raise ArgumentError, "sandbox mode must be one of: #{MODES.join(", ")}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def normalize_network(value)
|
|
57
|
+
network = value.to_s
|
|
58
|
+
return network if NETWORK_MODES.include?(network)
|
|
59
|
+
|
|
60
|
+
raise ArgumentError, "sandbox network must be one of: #{NETWORK_MODES.join(", ")}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def normalize_writable_roots(roots)
|
|
64
|
+
Array(roots).map { |root| canonical_path(root) }.uniq.reject { |root| root == workspace_root }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def canonical_path(path)
|
|
68
|
+
Pathname.new(path.to_s).realpath.to_s
|
|
69
|
+
rescue Errno::ENOENT
|
|
70
|
+
raise ArgumentError, "sandbox path does not exist: #{path}"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
require_relative "capabilities"
|
|
2
|
+
require_relative "passthrough_runner"
|
|
3
|
+
require_relative "unavailable_runner"
|
|
4
|
+
require_relative "macos_seatbelt_runner"
|
|
5
|
+
require_relative "linux_bubblewrap_runner"
|
|
6
|
+
|
|
7
|
+
# Namespace for operating-system command sandboxing.
|
|
8
|
+
module Kward
|
|
9
|
+
module Sandbox
|
|
10
|
+
# Selects the command runner that can enforce a policy on the current host.
|
|
11
|
+
module RunnerFactory
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def build(policy, platform: RUBY_PLATFORM)
|
|
15
|
+
return PassthroughRunner.new(policy:, capabilities: off_capabilities) unless policy.enabled?
|
|
16
|
+
|
|
17
|
+
runner_class = runner_class_for(platform)
|
|
18
|
+
return UnavailableRunner.new(policy:, capabilities: unsupported_capabilities(platform)) unless runner_class
|
|
19
|
+
|
|
20
|
+
capabilities = runner_class.capabilities(platform:)
|
|
21
|
+
return UnavailableRunner.new(policy:, capabilities:) unless capabilities.available?
|
|
22
|
+
|
|
23
|
+
runner_class.new(policy:, capabilities:)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def off_capabilities
|
|
27
|
+
Capabilities.new(
|
|
28
|
+
available: true,
|
|
29
|
+
filesystem_enforced: false,
|
|
30
|
+
child_network_enforced: false,
|
|
31
|
+
backend: "off"
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def unsupported_capabilities(platform)
|
|
36
|
+
Capabilities.new(
|
|
37
|
+
available: false,
|
|
38
|
+
filesystem_enforced: false,
|
|
39
|
+
child_network_enforced: false,
|
|
40
|
+
backend: "unsupported",
|
|
41
|
+
reason: "Sandboxing is unsupported on #{platform}"
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def runner_class_for(platform)
|
|
46
|
+
value = platform.to_s
|
|
47
|
+
return MacOSSeatbeltRunner if value.include?("darwin")
|
|
48
|
+
return LinuxBubblewrapRunner if value.include?("linux")
|
|
49
|
+
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
private_class_method :runner_class_for
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
require_relative "command_runner"
|
|
2
|
+
|
|
3
|
+
# Namespace for operating-system command sandboxing.
|
|
4
|
+
module Kward
|
|
5
|
+
module Sandbox
|
|
6
|
+
# Raised when a requested sandbox cannot be enforced on this host.
|
|
7
|
+
class UnavailableError < StandardError
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Fails closed rather than running a requested sandbox policy unrestricted.
|
|
11
|
+
class UnavailableRunner < CommandRunner
|
|
12
|
+
def command_argv(command, cwd:)
|
|
13
|
+
[]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run(command, cwd:, timeout_seconds:, max_output_bytes:, cancellation: nil, &block)
|
|
17
|
+
raise UnavailableError, "Sandbox #{policy.mode} is unavailable: #{capabilities.reason}"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
require_relative "sandbox/policy"
|
|
2
|
+
require_relative "sandbox/capabilities"
|
|
3
|
+
require_relative "sandbox/environment"
|
|
4
|
+
require_relative "sandbox/command_runner"
|
|
5
|
+
require_relative "sandbox/passthrough_runner"
|
|
6
|
+
require_relative "sandbox/unavailable_runner"
|
|
7
|
+
require_relative "sandbox/macos_seatbelt_runner"
|
|
8
|
+
require_relative "sandbox/linux_bubblewrap_runner"
|
|
9
|
+
require_relative "sandbox/runner_factory"
|
data/lib/kward/session_store.rb
CHANGED
|
@@ -315,6 +315,16 @@ module Kward
|
|
|
315
315
|
recent_sessions(limit: limit, keep_empty_path: keep_empty_path)
|
|
316
316
|
end
|
|
317
317
|
|
|
318
|
+
# Lists all persisted sessions under this config directory for an explicit
|
|
319
|
+
# user-selected operation such as skill capture. Unlike #recent, this is not
|
|
320
|
+
# scoped to the store's current workspace.
|
|
321
|
+
def capture_candidates
|
|
322
|
+
pattern = File.join(@config_dir, "sessions", "**", "*.jsonl")
|
|
323
|
+
Dir.glob(pattern).filter_map { |path| session_info(path) }.sort_by(&:modified_at).reverse
|
|
324
|
+
rescue StandardError
|
|
325
|
+
[]
|
|
326
|
+
end
|
|
327
|
+
|
|
318
328
|
# Persists the last active session pointer for workspace auto-resume.
|
|
319
329
|
def remember_last_session(session)
|
|
320
330
|
return unless session&.path
|
|
@@ -430,6 +440,22 @@ module Kward
|
|
|
430
440
|
current_leaf_id(records_from_file(resolve_session_path(path)))
|
|
431
441
|
end
|
|
432
442
|
|
|
443
|
+
# Returns the complete persisted active branch for an explicit export-like
|
|
444
|
+
# consumer. Unlike #load, this does not attach a conversation or mutate the
|
|
445
|
+
# session file.
|
|
446
|
+
#
|
|
447
|
+
# @return [Hash] session metadata, saved system-prompt snapshots, and active branch records
|
|
448
|
+
def capture_branch(path)
|
|
449
|
+
resolved_path = resolve_session_path(path)
|
|
450
|
+
records = records_from_file(resolved_path)
|
|
451
|
+
{
|
|
452
|
+
path: resolved_path,
|
|
453
|
+
header: session_header(records, resolved_path),
|
|
454
|
+
system_prompts: records.select { |record| record["type"] == "system_prompt" },
|
|
455
|
+
entries: branch_records(records)
|
|
456
|
+
}
|
|
457
|
+
end
|
|
458
|
+
|
|
433
459
|
def append_record(path, record)
|
|
434
460
|
File.open(path, "a", 0o600) do |file|
|
|
435
461
|
file.write(JSON.generate(record))
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "json"
|
|
3
|
+
require "tempfile"
|
|
4
|
+
require "yaml"
|
|
5
|
+
require_relative "../compaction/token_estimator"
|
|
6
|
+
require_relative "../config_files"
|
|
7
|
+
require_relative "../message_access"
|
|
8
|
+
|
|
9
|
+
# Namespace for the Kward CLI agent runtime.
|
|
10
|
+
module Kward
|
|
11
|
+
module Skills
|
|
12
|
+
# Generates and persists reviewed personal skills from saved session branches.
|
|
13
|
+
class Capture
|
|
14
|
+
Draft = Struct.new(:content, :source_path, :name, :description, keyword_init: true)
|
|
15
|
+
|
|
16
|
+
class Error < StandardError; end
|
|
17
|
+
class SourceTooLargeError < Error; end
|
|
18
|
+
class InvalidDraftError < Error; end
|
|
19
|
+
class ConflictError < Error; end
|
|
20
|
+
|
|
21
|
+
OUTPUT_TOKEN_RESERVE = 4_096
|
|
22
|
+
PROMPT_TOKEN_RESERVE = 1_024
|
|
23
|
+
NAME_PATTERN = /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/
|
|
24
|
+
|
|
25
|
+
def initialize(session_store:, client:, config_dir: ConfigFiles.config_dir, token_estimator: Compaction::TokenEstimator.new)
|
|
26
|
+
@session_store = session_store
|
|
27
|
+
@client = client
|
|
28
|
+
@config_dir = config_dir
|
|
29
|
+
@token_estimator = token_estimator
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def generate(session_path, cancellation: nil)
|
|
33
|
+
ensure_personal_session_path!(session_path)
|
|
34
|
+
source = @session_store.capture_branch(session_path)
|
|
35
|
+
raise Error, "Session has no active branch to capture" if source[:entries].empty?
|
|
36
|
+
|
|
37
|
+
source_json = JSON.pretty_generate(source)
|
|
38
|
+
ensure_source_fits!(source_json)
|
|
39
|
+
response = @client.chat(draft_messages(source_json), tools: [], cancellation: cancellation)
|
|
40
|
+
draft(source_path: source[:path], content: MessageAccess.content(response).to_s)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def save(content, overwrite: false)
|
|
44
|
+
reviewed_draft = draft(content: content)
|
|
45
|
+
path = skill_path(reviewed_draft.name)
|
|
46
|
+
if File.exist?(path) && !overwrite
|
|
47
|
+
raise ConflictError, "A personal skill named #{reviewed_draft.name.inspect} already exists"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
|
|
51
|
+
write_atomically(path, reviewed_draft.content)
|
|
52
|
+
reviewed_draft
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def skill_path(name)
|
|
56
|
+
File.join(@config_dir, "skills", name.to_s, "SKILL.md")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def ensure_personal_session_path!(session_path)
|
|
62
|
+
sessions_root = File.realpath(File.join(@config_dir, "sessions"))
|
|
63
|
+
source_path = File.realpath(session_path)
|
|
64
|
+
return if source_path.start_with?("#{sessions_root}#{File::SEPARATOR}")
|
|
65
|
+
|
|
66
|
+
raise Error, "Skill capture source must be a persisted Kward session"
|
|
67
|
+
rescue Errno::ENOENT
|
|
68
|
+
raise Error, "Skill capture source must be a persisted Kward session"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def ensure_source_fits!(source_json)
|
|
72
|
+
context_window = @client.current_context_window
|
|
73
|
+
return unless context_window
|
|
74
|
+
|
|
75
|
+
available_tokens = context_window.to_i - OUTPUT_TOKEN_RESERVE - PROMPT_TOKEN_RESERVE
|
|
76
|
+
source_tokens = @token_estimator.estimate_tokens(source_json)
|
|
77
|
+
return if source_tokens <= available_tokens
|
|
78
|
+
|
|
79
|
+
raise SourceTooLargeError,
|
|
80
|
+
"Selected session needs about #{source_tokens} tokens, but only #{[available_tokens, 0].max} are available for capture"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def draft_messages(source_json)
|
|
84
|
+
[
|
|
85
|
+
{
|
|
86
|
+
role: "system",
|
|
87
|
+
content: <<~INSTRUCTIONS
|
|
88
|
+
Create one reusable Kward Agent Skill from the saved session supplied by the user.
|
|
89
|
+
Return only a complete SKILL.md document. Include YAML frontmatter with a lowercase
|
|
90
|
+
hyphenated `name` and a concise `description`, followed by practical reusable
|
|
91
|
+
instructions. Derive guidance from demonstrated workflow, commands, and conventions;
|
|
92
|
+
omit credentials, private values, and project-specific incidental details. Do not
|
|
93
|
+
claim the skill grants permissions or tool access.
|
|
94
|
+
INSTRUCTIONS
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
role: "user",
|
|
98
|
+
content: "Saved session active branch (complete persisted records):\n\n#{source_json}"
|
|
99
|
+
}
|
|
100
|
+
]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def draft(source_path: nil, content:)
|
|
104
|
+
metadata, body = parse_skill(content)
|
|
105
|
+
name = metadata.fetch("name", "").to_s.strip
|
|
106
|
+
description = metadata.fetch("description", "").to_s.strip
|
|
107
|
+
raise InvalidDraftError, "Skill frontmatter must include a name" if name.empty?
|
|
108
|
+
raise InvalidDraftError, "Skill name must use lowercase letters, digits, and hyphens" unless NAME_PATTERN.match?(name)
|
|
109
|
+
raise InvalidDraftError, "Skill frontmatter must include a description" if description.empty?
|
|
110
|
+
raise InvalidDraftError, "Skill description exceeds 1024 characters" if description.length > 1024
|
|
111
|
+
raise InvalidDraftError, "Skill instructions must not be empty" if body.strip.empty?
|
|
112
|
+
|
|
113
|
+
Draft.new(content: normalize_content(content), source_path: source_path, name: name, description: description)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def parse_skill(content)
|
|
117
|
+
match = content.to_s.match(/\A---\r?\n(.*?)\r?\n---\r?\n(.*)\z/m)
|
|
118
|
+
raise InvalidDraftError, "Skill must begin with YAML frontmatter" unless match
|
|
119
|
+
|
|
120
|
+
metadata = YAML.safe_load(match[1], permitted_classes: [], aliases: false)
|
|
121
|
+
raise InvalidDraftError, "Skill frontmatter must be a mapping" unless metadata.is_a?(Hash)
|
|
122
|
+
|
|
123
|
+
[metadata.transform_keys(&:to_s), match[2]]
|
|
124
|
+
rescue Psych::SyntaxError => error
|
|
125
|
+
raise InvalidDraftError, "Invalid skill frontmatter: #{error.message}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def normalize_content(content)
|
|
129
|
+
"#{content.to_s.rstrip}\n"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def write_atomically(path, content)
|
|
133
|
+
Tempfile.create(["SKILL", ".tmp"], File.dirname(path)) do |file|
|
|
134
|
+
file.write(content)
|
|
135
|
+
file.flush
|
|
136
|
+
file.fsync
|
|
137
|
+
File.chmod(0o600, file.path)
|
|
138
|
+
File.rename(file.path, path)
|
|
139
|
+
end
|
|
140
|
+
File.chmod(0o600, path)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -6,6 +6,7 @@ require "tmpdir"
|
|
|
6
6
|
require "uri"
|
|
7
7
|
require "zlib"
|
|
8
8
|
require_relative "config_files"
|
|
9
|
+
require_relative "http"
|
|
9
10
|
|
|
10
11
|
# Namespace for the Kward CLI agent runtime.
|
|
11
12
|
module Kward
|
|
@@ -56,8 +57,9 @@ module Kward
|
|
|
56
57
|
|
|
57
58
|
def download(url)
|
|
58
59
|
uri = URI(url)
|
|
60
|
+
request = Http.apply_user_agent(Net::HTTP::Get.new(uri))
|
|
59
61
|
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https", open_timeout: 10, read_timeout: 30) do |http|
|
|
60
|
-
http.
|
|
62
|
+
http.request(request)
|
|
61
63
|
end
|
|
62
64
|
raise "Starter pack download failed with HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
63
65
|
|