kward 0.78.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 +24 -0
- data/Gemfile.lock +2 -2
- data/README.md +4 -2
- data/doc/agent-tools.md +1 -1
- data/doc/configuration.md +68 -2
- data/doc/extensibility.md +2 -0
- data/doc/getting-started.md +1 -1
- data/doc/local-models.md +130 -0
- data/doc/permissions.md +3 -2
- data/doc/releasing.md +1 -1
- data/doc/rpc.md +12 -1
- data/doc/sandboxing.md +120 -0
- data/doc/security.md +11 -5
- data/doc/skills.md +10 -0
- data/doc/workspace-tools.md +3 -3
- data/kward.gemspec +1 -1
- data/lib/kward/cli/doctor.rb +21 -0
- data/lib/kward/cli/runtime_helpers.rb +1 -1
- data/lib/kward/cli/settings.rb +66 -4
- data/lib/kward/cli/slash_commands.rb +109 -1
- data/lib/kward/cli.rb +2 -1
- data/lib/kward/config_files.rb +80 -1
- data/lib/kward/conversation.rb +14 -1
- data/lib/kward/local_command_runner.rb +2 -2
- data/lib/kward/model/client.rb +140 -4
- data/lib/kward/model/model_info.rb +11 -1
- data/lib/kward/model/payloads.rb +1 -0
- data/lib/kward/model/stream_parser.rb +58 -26
- data/lib/kward/pan/index.html.erb +50 -0
- data/lib/kward/pan/server.rb +49 -2
- data/lib/kward/plugin_registry.rb +1 -1
- data/lib/kward/prompt_interface/editor/controller.rb +36 -3
- data/lib/kward/prompt_interface.rb +18 -0
- data/lib/kward/prompts/commands.rb +2 -0
- data/lib/kward/prompts.rb +16 -5
- data/lib/kward/rpc/plugin_chat_manager.rb +4 -1
- data/lib/kward/rpc/server.rb +42 -4
- data/lib/kward/rpc/session_manager.rb +42 -8
- 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/tools/registry.rb +19 -4
- 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 +4 -2
- data/templates/default/layout/html/setup.rb +1 -0
- metadata +16 -2
|
@@ -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
|
data/lib/kward/tools/registry.rb
CHANGED
|
@@ -49,6 +49,19 @@ module Kward
|
|
|
49
49
|
# explicit tool errors.
|
|
50
50
|
# @api public
|
|
51
51
|
class ToolRegistry
|
|
52
|
+
CORE_TOOL_NAMES = %w[
|
|
53
|
+
list_directory
|
|
54
|
+
read_file
|
|
55
|
+
write_file
|
|
56
|
+
edit_file
|
|
57
|
+
run_shell_command
|
|
58
|
+
code_search
|
|
59
|
+
summarize_file_structure
|
|
60
|
+
context_for_task
|
|
61
|
+
context_budget_stats
|
|
62
|
+
retrieve_tool_output
|
|
63
|
+
].freeze
|
|
64
|
+
|
|
52
65
|
# Tool schemas advertised to the model for the current frontend and config.
|
|
53
66
|
#
|
|
54
67
|
# @return [Array<Hash>] tool schemas currently advertised to the model
|
|
@@ -462,13 +475,15 @@ module Kward
|
|
|
462
475
|
def build_tools
|
|
463
476
|
tools = all_tools
|
|
464
477
|
tools = tools.select { |tool| @allowed_tool_names.include?(tool.name) } if @allowed_tool_names
|
|
465
|
-
tools.
|
|
478
|
+
tools.each_with_object({}) do |tool, result|
|
|
479
|
+
raise ArgumentError, "Duplicate tool name: #{tool.name}" if result.key?(tool.name)
|
|
480
|
+
|
|
481
|
+
result[tool.name] = tool
|
|
482
|
+
end
|
|
466
483
|
end
|
|
467
484
|
|
|
468
485
|
def build_schema_tools
|
|
469
|
-
tools = @tools.values_at(
|
|
470
|
-
"list_directory", "read_file", "write_file", "edit_file", "run_shell_command", "code_search", "summarize_file_structure", "context_for_task", "context_budget_stats", "retrieve_tool_output"
|
|
471
|
-
)
|
|
486
|
+
tools = @tools.values_at(*CORE_TOOL_NAMES)
|
|
472
487
|
tools.concat(@tools.values_at("web_search", "fetch_content", "fetch_raw")) if web_search_available?
|
|
473
488
|
tools.concat(@tools.values.select { |tool| tool.is_a?(Tools::MCPTool) })
|
|
474
489
|
tools << @tools["read_skill"] if skills_available?
|
data/lib/kward/version.rb
CHANGED
data/lib/kward/workspace.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require "pathname"
|
|
2
2
|
require_relative "local_command_runner"
|
|
3
|
+
require_relative "sandbox"
|
|
3
4
|
require_relative "session_diff"
|
|
4
5
|
|
|
5
6
|
require_relative "path_guard"
|
|
@@ -27,13 +28,14 @@ module Kward
|
|
|
27
28
|
EXPECTED_FILE_ERRORS = [SecurityError, Errno::ENOENT, Errno::EACCES, Errno::EPERM, Errno::EISDIR, Errno::ENOTDIR].freeze
|
|
28
29
|
|
|
29
30
|
# Creates an object for workspace filesystem and shell operations.
|
|
30
|
-
def initialize(root: Dir.pwd, max_file_bytes: MAX_FILE_BYTES, max_read_output_bytes: MAX_READ_OUTPUT_BYTES, max_read_output_lines: MAX_READ_OUTPUT_LINES, max_command_output_bytes: MAX_COMMAND_OUTPUT_BYTES, guardrails: true)
|
|
31
|
+
def initialize(root: Dir.pwd, max_file_bytes: MAX_FILE_BYTES, max_read_output_bytes: MAX_READ_OUTPUT_BYTES, max_read_output_lines: MAX_READ_OUTPUT_LINES, max_command_output_bytes: MAX_COMMAND_OUTPUT_BYTES, guardrails: true, command_runner: nil)
|
|
31
32
|
@root = Pathname.new(root).realpath
|
|
32
33
|
@guardrails = guardrails
|
|
33
34
|
@max_file_bytes = max_file_bytes
|
|
34
35
|
@max_read_output_bytes = max_read_output_bytes
|
|
35
36
|
@max_read_output_lines = max_read_output_lines
|
|
36
37
|
@max_command_output_bytes = max_command_output_bytes
|
|
38
|
+
@command_runner = command_runner
|
|
37
39
|
end
|
|
38
40
|
|
|
39
41
|
# @return [Pathname] canonical workspace root used as the base for file and shell tools
|
|
@@ -162,7 +164,13 @@ module Kward
|
|
|
162
164
|
timeout_seconds = DEFAULT_COMMAND_TIMEOUT_SECONDS if timeout_seconds <= 0
|
|
163
165
|
cancellation&.raise_if_cancelled!
|
|
164
166
|
|
|
165
|
-
result =
|
|
167
|
+
result = command_runner.run(
|
|
168
|
+
command,
|
|
169
|
+
cwd: @root.to_s,
|
|
170
|
+
timeout_seconds: timeout_seconds,
|
|
171
|
+
max_output_bytes: @max_command_output_bytes,
|
|
172
|
+
cancellation: cancellation
|
|
173
|
+
)
|
|
166
174
|
return "Error: command timed out after #{timeout_seconds} seconds" if result.timed_out
|
|
167
175
|
|
|
168
176
|
output = +"Exit status: #{result.exit_status}\n"
|
|
@@ -170,7 +178,7 @@ module Kward
|
|
|
170
178
|
output << "\nSTDERR:\n#{result.stderr}" unless result.stderr.empty?
|
|
171
179
|
output << "\n... truncated to #{@max_command_output_bytes} bytes" if result.truncated
|
|
172
180
|
truncate_output(output)
|
|
173
|
-
rescue Errno::ENOENT, ArgumentError => e
|
|
181
|
+
rescue Errno::ENOENT, ArgumentError, Sandbox::UnavailableError => e
|
|
174
182
|
"Error: #{e.message}"
|
|
175
183
|
end
|
|
176
184
|
|
|
@@ -181,6 +189,13 @@ module Kward
|
|
|
181
189
|
|
|
182
190
|
private
|
|
183
191
|
|
|
192
|
+
def command_runner
|
|
193
|
+
@command_runner ||= Sandbox::PassthroughRunner.new(
|
|
194
|
+
policy: Sandbox::Policy.new(workspace_root: @root),
|
|
195
|
+
capabilities: Sandbox::RunnerFactory.off_capabilities
|
|
196
|
+
)
|
|
197
|
+
end
|
|
198
|
+
|
|
184
199
|
def workspace_path(path)
|
|
185
200
|
target = Pathname.new(path.to_s)
|
|
186
201
|
target = @root.join(target) unless target.absolute?
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
require_relative "config_files"
|
|
2
|
+
require_relative "sandbox"
|
|
3
|
+
require_relative "workspace"
|
|
4
|
+
|
|
5
|
+
# Namespace for the Kward CLI agent runtime.
|
|
6
|
+
module Kward
|
|
7
|
+
# Builds workspaces with the same user-configured command sandbox in every frontend.
|
|
8
|
+
module WorkspaceFactory
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def build(root:, guardrails: true, config: ConfigFiles.read_config)
|
|
12
|
+
policy = ConfigFiles.sandbox_policy(root, config)
|
|
13
|
+
runner = Sandbox::RunnerFactory.build(policy)
|
|
14
|
+
Workspace.new(root:, guardrails:, command_runner: runner)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -4,6 +4,7 @@ const guideLinks = {
|
|
|
4
4
|
'doc/usage.md': 'file.usage.html',
|
|
5
5
|
'doc/configuration.md': 'file.configuration.html',
|
|
6
6
|
'doc/authentication.md': 'file.authentication.html',
|
|
7
|
+
'doc/sandboxing.md': 'file.sandboxing.html',
|
|
7
8
|
'doc/troubleshooting.md': 'file.troubleshooting.html',
|
|
8
9
|
'doc/session-management.md': 'file.session-management.html',
|
|
9
10
|
'doc/memory.md': 'file.memory.html',
|
|
@@ -22,7 +22,8 @@ module KwardDocsNavigationData
|
|
|
22
22
|
["Memory", "file.memory.html"],
|
|
23
23
|
["Personas", "file.personas.html"],
|
|
24
24
|
["Skills", "file.skills.html"],
|
|
25
|
-
["Pan mode", "file.pan.html"]
|
|
25
|
+
["Pan mode", "file.pan.html"],
|
|
26
|
+
["Local models", "file.local-models.html"]
|
|
26
27
|
]
|
|
27
28
|
],
|
|
28
29
|
[
|
|
@@ -43,7 +44,8 @@ module KwardDocsNavigationData
|
|
|
43
44
|
["Extensibility", "file.extensibility.html"],
|
|
44
45
|
["Plugins", "file.plugins.html"],
|
|
45
46
|
["Lifecycle hooks", "file.lifecycle-hooks.html"],
|
|
46
|
-
["Permissions", "file.permissions.html"]
|
|
47
|
+
["Permissions", "file.permissions.html"],
|
|
48
|
+
["Command sandboxing", "file.sandboxing.html"]
|
|
47
49
|
]
|
|
48
50
|
],
|
|
49
51
|
[
|
|
@@ -30,6 +30,7 @@ module KwardDocsNavigation
|
|
|
30
30
|
"doc/configuration.md" => "file.configuration.html",
|
|
31
31
|
"doc/authentication.md" => "file.authentication.html",
|
|
32
32
|
"doc/permissions.md" => "file.permissions.html",
|
|
33
|
+
"doc/sandboxing.md" => "file.sandboxing.html",
|
|
33
34
|
"doc/troubleshooting.md" => "file.troubleshooting.html",
|
|
34
35
|
"doc/session-management.md" => "file.session-management.html",
|
|
35
36
|
"doc/tabs.md" => "file.tabs.html",
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: kward
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.79.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kai Wood
|
|
@@ -139,6 +139,7 @@ files:
|
|
|
139
139
|
- doc/getting-started.md
|
|
140
140
|
- doc/git.md
|
|
141
141
|
- doc/lifecycle-hooks.md
|
|
142
|
+
- doc/local-models.md
|
|
142
143
|
- doc/mcp.md
|
|
143
144
|
- doc/memory.md
|
|
144
145
|
- doc/pan.md
|
|
@@ -147,6 +148,7 @@ files:
|
|
|
147
148
|
- doc/plugins.md
|
|
148
149
|
- doc/releasing.md
|
|
149
150
|
- doc/rpc.md
|
|
151
|
+
- doc/sandboxing.md
|
|
150
152
|
- doc/security.md
|
|
151
153
|
- doc/session-management.md
|
|
152
154
|
- doc/shell.md
|
|
@@ -314,6 +316,16 @@ files:
|
|
|
314
316
|
- lib/kward/rpc/transcript_normalizer.rb
|
|
315
317
|
- lib/kward/rpc/transport.rb
|
|
316
318
|
- lib/kward/rpc/turn_context.rb
|
|
319
|
+
- lib/kward/sandbox.rb
|
|
320
|
+
- lib/kward/sandbox/capabilities.rb
|
|
321
|
+
- lib/kward/sandbox/command_runner.rb
|
|
322
|
+
- lib/kward/sandbox/environment.rb
|
|
323
|
+
- lib/kward/sandbox/linux_bubblewrap_runner.rb
|
|
324
|
+
- lib/kward/sandbox/macos_seatbelt_runner.rb
|
|
325
|
+
- lib/kward/sandbox/passthrough_runner.rb
|
|
326
|
+
- lib/kward/sandbox/policy.rb
|
|
327
|
+
- lib/kward/sandbox/runner_factory.rb
|
|
328
|
+
- lib/kward/sandbox/unavailable_runner.rb
|
|
317
329
|
- lib/kward/scratchpad_runner.rb
|
|
318
330
|
- lib/kward/session_diff.rb
|
|
319
331
|
- lib/kward/session_naming.rb
|
|
@@ -322,6 +334,7 @@ files:
|
|
|
322
334
|
- lib/kward/session_tree_nodes.rb
|
|
323
335
|
- lib/kward/session_tree_renderer.rb
|
|
324
336
|
- lib/kward/session_tree_tool_display.rb
|
|
337
|
+
- lib/kward/skills/capture.rb
|
|
325
338
|
- lib/kward/skills/registry.rb
|
|
326
339
|
- lib/kward/starter_pack_installer.rb
|
|
327
340
|
- lib/kward/steering.rb
|
|
@@ -359,6 +372,7 @@ files:
|
|
|
359
372
|
- lib/kward/update_check.rb
|
|
360
373
|
- lib/kward/version.rb
|
|
361
374
|
- lib/kward/workspace.rb
|
|
375
|
+
- lib/kward/workspace_factory.rb
|
|
362
376
|
- lib/main.rb
|
|
363
377
|
- templates/default/fulldoc/html/css/kward.css
|
|
364
378
|
- templates/default/fulldoc/html/full_list.erb
|
|
@@ -386,7 +400,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
386
400
|
requirements:
|
|
387
401
|
- - ">="
|
|
388
402
|
- !ruby/object:Gem::Version
|
|
389
|
-
version: '3.
|
|
403
|
+
version: '3.4'
|
|
390
404
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
391
405
|
requirements:
|
|
392
406
|
- - ">="
|