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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 52db0d4ce078507aa4a72cbc84d54a9433788f2a2d9a90ab883a9e469e6dea99
4
- data.tar.gz: 107b34e1c3b387b4b68d542ff8ab2345885383a461c227a6b7aeb29973a4292e
3
+ metadata.gz: 3918af9bb28c07c5706f2738899b05cda0a9c65a3a59b7e10799a98c8b8bc0b8
4
+ data.tar.gz: 142a335f7f42891c0f9f5cd9964dc31202691f1fa470fef526ad61280a2f8bff
5
5
  SHA512:
6
- metadata.gz: 432af6cfc0a0f3555666e9c88accb0e9b6162af2c5f041c9ff71b10443f1681b8e70b9e46aa6c75ed12344357a286df087869b889f0a40aeb9635aa6c9a1e651
7
- data.tar.gz: 362eb9437127e8734f15e09919ae7fb928e0d1b71fc9bb90a78f419cb7b4f52f29aebd323dd32e7bd491b0a73a3de60cff54a91e1756f24bcdc4e91d52de5fc2
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?(prompt:)`. Pass `temp: true` to mint an ephemeral
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 fully-composed prompt String. Tools do
29
- # *not* call +gets+ / +puts+ directly same lesson as listeners,
30
- # keep IO at the seam so a future TUI / web client can plug a
31
- # different implementation in without touching tool code.
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
- # @param prompt [String] human-readable question composed by the
34
- # calling tool. The confirmer renders it and parses the answer;
35
- # it does NOT compose its own prompt content. Caller owns the
36
- # closing punctuation and any "(y/n)" cue.
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?(prompt:)
71
+ def confirm?(request:)
40
72
  raise NotImplementedError, "#{self.class}#confirm? must be implemented"
41
73
  end
42
74
 
43
- # Stdin/stdout implementation: prints +prompt+ on its own line (a
44
- # leading +puts+ guarantees separation from any streamed output
45
- # the +Terminal+ listener may have produced just above), reads one
46
- # line from +$stdin+, parses it strictly:
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 prompt [String]
107
+ # @param request [Request]
57
108
  # @return [Boolean]
58
- def confirm?(prompt:)
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
- puts prompt
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 prompt [String] ignored
142
+ # @param request [Request] ignored
82
143
  # @return [true]
83
- def confirm?(prompt:)
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 {CACHE_BASE}. Registered with {Pikuri::Finalizers} for
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(CACHE_BASE)
302
- path = Pathname.new(Dir.mktmpdir('workspace-', CACHE_BASE)).realpath
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
- # {CACHE_BASE}. Failures (permission denied, racing concurrent
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?(CACHE_BASE)
310
+ return unless File.directory?(Paths.cache)
317
311
 
318
312
  cutoff = Time.now - INTERNAL_TEMP_STALE_SECONDS
319
- Dir.children(CACHE_BASE).each do |entry|
313
+ Dir.children(Paths.cache).each do |entry|
320
314
  next unless entry.start_with?('workspace-')
321
- path = File.join(CACHE_BASE, entry)
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
- prompt = "OK to overwrite #{path}: #{existing.bytesize} → #{content.bytesize} bytes? (y/n)"
115
- return "Error: user declined the write to #{path}." unless confirmer.confirm?(prompt: prompt)
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.6
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: 2026-06-04 00:00:00.000000000 Z
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.6
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.6
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.5.22
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.