pikuri-workspace 0.0.6 → 0.0.7
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/README.md +1 -1
- data/lib/pikuri/workspace/confirmer.rb +79 -18
- data/lib/pikuri/workspace/filesystem.rb +7 -13
- data/lib/pikuri/workspace/write.rb +4 -2
- metadata +5 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3918af9bb28c07c5706f2738899b05cda0a9c65a3a59b7e10799a98c8b8bc0b8
|
|
4
|
+
data.tar.gz: 142a335f7f42891c0f9f5cd9964dc31202691f1fa470fef526ad61280a2f8bff
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a20b8468fa2fc9cbed1aed620b645443cc6fb8969aad611de24e8620c88efe16b692265aabb5520d0c5231f5f6da2c31a0cae3f815eed2fd4e7840ef13e637d5
|
|
7
|
+
data.tar.gz: b5c6f5bc61a0f45641127a252c5f55bac9e0d3f5bb5ee577abeed31419859454daf7b7db26a0f5880cd638304ab3bd7da5d63b910093c4223372f21b5a97f2cb
|
data/README.md
CHANGED
|
@@ -49,7 +49,7 @@ end
|
|
|
49
49
|
`Workspace` is the "look-but-don't-leak" guard around filesystem
|
|
50
50
|
access. Read tools route through `#resolve_for_read(path)`; mutating
|
|
51
51
|
tools route through `#resolve_for_write(path)` + the Confirmer's
|
|
52
|
-
`#confirm?(
|
|
52
|
+
`#confirm?(request:)`. Pass `temp: true` to mint an ephemeral
|
|
53
53
|
writable playground via `Dir.mktmpdir` — its path is exposed as
|
|
54
54
|
`workspace.temp` and auto-removed at process exit.
|
|
55
55
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'rainbow'
|
|
4
|
+
|
|
3
5
|
module Pikuri
|
|
4
6
|
module Workspace
|
|
5
7
|
# Port for asking the user to confirm a potentially destructive tool
|
|
@@ -25,25 +27,74 @@ module Pikuri
|
|
|
25
27
|
# == Seam discipline
|
|
26
28
|
#
|
|
27
29
|
# Tools that need confirmation take a {Confirmer} via constructor and
|
|
28
|
-
# invoke {#confirm?} with a
|
|
29
|
-
# *
|
|
30
|
-
#
|
|
31
|
-
#
|
|
30
|
+
# invoke {#confirm?} with a semantic {Request} — *what* is being
|
|
31
|
+
# asked, never *how* it should look. All presentation belongs to the
|
|
32
|
+
# confirmer implementation: color, the answer cue, answer parsing,
|
|
33
|
+
# and — security-relevant — neutralizing hostile bytes in
|
|
34
|
+
# LLM-supplied text. The chrome-independent half of that (escape
|
|
35
|
+
# control bytes, flag bidi / zero-width / homoglyph spoofs) is the
|
|
36
|
+
# shared {Pikuri::Sanitizer}; the medium-specific half stays with the
|
|
37
|
+
# renderer that knows its medium (a terminal prints the sanitized
|
|
38
|
+
# text directly; a web client wraps it in HTML-escaping). Tools do *not*
|
|
39
|
+
# call +gets+ / +puts+ directly — same lesson as listeners, keep IO
|
|
40
|
+
# at the seam so a TUI / web client can plug a different
|
|
41
|
+
# implementation in without touching tool code.
|
|
32
42
|
class Confirmer
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
43
|
+
# The semantic payload of one confirmation. Two fields:
|
|
44
|
+
#
|
|
45
|
+
# * +question+ — one-line headline composed by the calling tool,
|
|
46
|
+
# e.g. +"OK to overwrite foo.rb: 120 → 245 bytes?"+. The caller
|
|
47
|
+
# owns the phrasing and punctuation; the renderer owns the
|
|
48
|
+
# answer cue (+"(y/n)?"+, buttons, ...).
|
|
49
|
+
# * +detail+ — optional preformatted body, possibly multi-line:
|
|
50
|
+
# the raw bash command for {Pikuri::Code::Bash}, +nil+ for
|
|
51
|
+
# {Write}. Renderers typically display it monospaced / dimmed.
|
|
52
|
+
#
|
|
53
|
+
# Both fields are RAW, straight from tool arguments the LLM
|
|
54
|
+
# composed (+detail+ is the command verbatim; +question+ may embed
|
|
55
|
+
# an LLM-written description). Renderers MUST neutralize them before
|
|
56
|
+
# display — route through {Pikuri::Sanitizer} (see {Terminal}), and
|
|
57
|
+
# additionally HTML-escape in a web client.
|
|
58
|
+
Request = Data.define(:question, :detail) do
|
|
59
|
+
# @param question [String] one-line headline; caller owns phrasing
|
|
60
|
+
# @param detail [String, nil] optional preformatted body
|
|
61
|
+
def initialize(question:, detail: nil)
|
|
62
|
+
super
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @param request [Request] semantic content composed by the
|
|
67
|
+
# calling tool. The confirmer renders it (escaping for its
|
|
68
|
+
# medium), poses the question, and parses the answer.
|
|
37
69
|
# @return [Boolean] +true+ iff approved
|
|
38
70
|
# @raise [NotImplementedError] in the abstract base
|
|
39
|
-
def confirm?(
|
|
71
|
+
def confirm?(request:)
|
|
40
72
|
raise NotImplementedError, "#{self.class}#confirm? must be implemented"
|
|
41
73
|
end
|
|
42
74
|
|
|
43
|
-
# Stdin/stdout implementation
|
|
44
|
-
# leading +puts+ guarantees separation from any streamed
|
|
45
|
-
# the +Terminal+ listener may have produced just above)
|
|
46
|
-
#
|
|
75
|
+
# Stdin/stdout implementation. Renders the request as up to several
|
|
76
|
+
# lines (a leading +puts+ guarantees separation from any streamed
|
|
77
|
+
# output the +Terminal+ listener may have produced just above):
|
|
78
|
+
#
|
|
79
|
+
# 1. a bold-yellow warning block — one line per
|
|
80
|
+
# {Pikuri::Sanitizer::Warning} — shown only when the sanitizer
|
|
81
|
+
# flagged something suspicious in the question or detail
|
|
82
|
+
# 2. the question, bold
|
|
83
|
+
# 3. the detail, dim — omitted when +nil+
|
|
84
|
+
# 4. the +(y/n)?+ cue
|
|
85
|
+
#
|
|
86
|
+
# Both question and detail pass through {Pikuri::Sanitizer}, which
|
|
87
|
+
# neutralizes control bytes — without it, a model could craft a
|
|
88
|
+
# command or description containing +"\rrm -rf ~/"+ that visually
|
|
89
|
+
# overwrites the echoed line after the user has already read it —
|
|
90
|
+
# and reports *why* it was unsafe so the user reads the warning
|
|
91
|
+
# before answering. Colors come from Rainbow (already in the
|
|
92
|
+
# dependency closure via pikuri-core), which self-disables on
|
|
93
|
+
# non-TTY output; the bold-yellow warning rendering is this
|
|
94
|
+
# terminal chrome's call, not the sanitizer's (the +Warning+
|
|
95
|
+
# carries plain text only).
|
|
96
|
+
#
|
|
97
|
+
# Then reads one line from +$stdin+ and parses it strictly:
|
|
47
98
|
#
|
|
48
99
|
# * +"y"+ / +"yes"+ (case-insensitive, stripped) → +true+
|
|
49
100
|
# * +"n"+ / +"no"+ → +false+
|
|
@@ -53,11 +104,21 @@ module Pikuri
|
|
|
53
104
|
#
|
|
54
105
|
# No retry cap; EOF eventually breaks adversarial input.
|
|
55
106
|
class Terminal < Confirmer
|
|
56
|
-
# @param
|
|
107
|
+
# @param request [Request]
|
|
57
108
|
# @return [Boolean]
|
|
58
|
-
def confirm?(
|
|
109
|
+
def confirm?(request:)
|
|
110
|
+
question = Pikuri::Sanitizer.sanitize(request.question)
|
|
111
|
+
detail = request.detail ? Pikuri::Sanitizer.sanitize(request.detail) : nil
|
|
112
|
+
warnings = question.warnings + (detail ? detail.warnings : [])
|
|
113
|
+
|
|
59
114
|
puts
|
|
60
|
-
|
|
115
|
+
unless warnings.empty?
|
|
116
|
+
puts Rainbow('⚠ Suspicious content detected — read carefully before approving:').yellow.bold
|
|
117
|
+
warnings.each { |w| puts Rainbow(" ! #{w.explanation}").yellow }
|
|
118
|
+
end
|
|
119
|
+
puts Rainbow(question.text).bold
|
|
120
|
+
puts Rainbow(detail.text).dimgray if detail
|
|
121
|
+
puts '(y/n)?'
|
|
61
122
|
$stdout.flush
|
|
62
123
|
loop do
|
|
63
124
|
line = $stdin.gets
|
|
@@ -78,9 +139,9 @@ module Pikuri
|
|
|
78
139
|
# coordinate stdin. The name +AUTO_APPROVE+ matches the public
|
|
79
140
|
# constant {AUTO_APPROVE}.
|
|
80
141
|
class AutoApprove < Confirmer
|
|
81
|
-
# @param
|
|
142
|
+
# @param request [Request] ignored
|
|
82
143
|
# @return [true]
|
|
83
|
-
def confirm?(
|
|
144
|
+
def confirm?(request:)
|
|
84
145
|
true
|
|
85
146
|
end
|
|
86
147
|
end
|
|
@@ -174,12 +174,6 @@ module Pikuri
|
|
|
174
174
|
# raised at construction time for a denied project root.
|
|
175
175
|
class Error < StandardError; end
|
|
176
176
|
|
|
177
|
-
# Parent directory under which every workspace mints its
|
|
178
|
-
# umbrella ({#internal_temp}). Honors +XDG_CACHE_HOME+ when set,
|
|
179
|
-
# else +~/.cache+; the +pikuri+ subdir is owned by us.
|
|
180
|
-
# +mkdir_p+'d lazily on first umbrella access.
|
|
181
|
-
CACHE_BASE = File.join(ENV['XDG_CACHE_HOME'] || File.join(Dir.home, '.cache'), 'pikuri')
|
|
182
|
-
|
|
183
177
|
# Umbrella dirs older than this are reaped by
|
|
184
178
|
# {.sweep_stale_internal_temps!} at gem load. Generous enough
|
|
185
179
|
# that a long-lived pikuri session in another shell isn't
|
|
@@ -279,7 +273,7 @@ module Pikuri
|
|
|
279
273
|
end
|
|
280
274
|
|
|
281
275
|
# Per-workspace ephemeral umbrella. Minted lazily on first call
|
|
282
|
-
# under {
|
|
276
|
+
# under {Paths::cache}. Registered with {Pikuri::Finalizers} for
|
|
283
277
|
# removal the moment it's minted, so anything subsequently placed inside
|
|
284
278
|
# (the playground, {Pikuri::Code::Bash::Sandbox::Bubblewrap}'s
|
|
285
279
|
# overlay state) gets wiped together. Callers that want
|
|
@@ -298,8 +292,8 @@ module Pikuri
|
|
|
298
292
|
# one registry. The +path.exist?+ guard makes the removal a no-op
|
|
299
293
|
# when the dir is already gone (test cleanup, manual rm).
|
|
300
294
|
def self.mint_internal_temp
|
|
301
|
-
FileUtils.mkdir_p(
|
|
302
|
-
path = Pathname.new(Dir.mktmpdir('workspace-',
|
|
295
|
+
FileUtils.mkdir_p(Paths.cache)
|
|
296
|
+
path = Pathname.new(Dir.mktmpdir('workspace-', Paths.cache)).realpath
|
|
303
297
|
Pikuri::Finalizers.register { FileUtils.remove_entry(path.to_s) if path.exist? }
|
|
304
298
|
path
|
|
305
299
|
end
|
|
@@ -307,18 +301,18 @@ module Pikuri
|
|
|
307
301
|
# Reap +workspace-*+ umbrella dirs that have outlived
|
|
308
302
|
# {INTERNAL_TEMP_STALE_SECONDS}. Called once at gem load via
|
|
309
303
|
# {Pikuri::Workspace} so each process boot inherits a tidy
|
|
310
|
-
# {
|
|
304
|
+
# {Paths::cache}. Failures (permission denied, racing concurrent
|
|
311
305
|
# sweeper) are swallowed — best-effort cleanup; the
|
|
312
306
|
# {Pikuri::Finalizers} removal is the load-bearing path.
|
|
313
307
|
#
|
|
314
308
|
# @return [void]
|
|
315
309
|
def self.sweep_stale_internal_temps!
|
|
316
|
-
return unless File.directory?(
|
|
310
|
+
return unless File.directory?(Paths.cache)
|
|
317
311
|
|
|
318
312
|
cutoff = Time.now - INTERNAL_TEMP_STALE_SECONDS
|
|
319
|
-
Dir.children(
|
|
313
|
+
Dir.children(Paths.cache).each do |entry|
|
|
320
314
|
next unless entry.start_with?('workspace-')
|
|
321
|
-
path = File.join(
|
|
315
|
+
path = File.join(Paths.cache, entry)
|
|
322
316
|
next unless File.directory?(path)
|
|
323
317
|
next if File.mtime(path) > cutoff
|
|
324
318
|
|
|
@@ -111,8 +111,10 @@ module Pikuri
|
|
|
111
111
|
'file and try again.'
|
|
112
112
|
end
|
|
113
113
|
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
request = Confirmer::Request.new(
|
|
115
|
+
question: "OK to overwrite #{path}: #{existing.bytesize} → #{content.bytesize} bytes?"
|
|
116
|
+
)
|
|
117
|
+
return "Error: user declined the write to #{path}." unless confirmer.confirm?(request: request)
|
|
116
118
|
|
|
117
119
|
write_bytes(resolved, content)
|
|
118
120
|
"Updated #{path} (#{existing.bytesize} → #{content.bytesize} bytes)"
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: pikuri-workspace
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0.
|
|
4
|
+
version: 0.0.7
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Martin Vysny
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: pikuri-core
|
|
@@ -16,14 +15,14 @@ dependencies:
|
|
|
16
15
|
requirements:
|
|
17
16
|
- - '='
|
|
18
17
|
- !ruby/object:Gem::Version
|
|
19
|
-
version: 0.0.
|
|
18
|
+
version: 0.0.7
|
|
20
19
|
type: :runtime
|
|
21
20
|
prerelease: false
|
|
22
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
23
22
|
requirements:
|
|
24
23
|
- - '='
|
|
25
24
|
- !ruby/object:Gem::Version
|
|
26
|
-
version: 0.0.
|
|
25
|
+
version: 0.0.7
|
|
27
26
|
description: |
|
|
28
27
|
pikuri-workspace adds "operate on a directory tree" to pikuri-core
|
|
29
28
|
agents: the +Pikuri::Workspace::Filesystem+ class that scopes
|
|
@@ -59,7 +58,6 @@ metadata:
|
|
|
59
58
|
changelog_uri: https://codeberg.org/mvysny/pikuri/src/branch/master/CHANGELOG.md
|
|
60
59
|
bug_tracker_uri: https://codeberg.org/mvysny/pikuri/issues
|
|
61
60
|
rubygems_mfa_required: 'true'
|
|
62
|
-
post_install_message:
|
|
63
61
|
rdoc_options: []
|
|
64
62
|
require_paths:
|
|
65
63
|
- lib
|
|
@@ -74,8 +72,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
74
72
|
- !ruby/object:Gem::Version
|
|
75
73
|
version: '0'
|
|
76
74
|
requirements: []
|
|
77
|
-
rubygems_version: 3.
|
|
78
|
-
signing_key:
|
|
75
|
+
rubygems_version: 3.6.7
|
|
79
76
|
specification_version: 4
|
|
80
77
|
summary: Filesystem tools (Read/Write/Edit/Grep/Glob) + Workspace + Confirmer seams
|
|
81
78
|
for pikuri.
|