pikuri-core 0.0.7 → 0.1.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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/pikuri/agent/chat_transport.rb +73 -93
  4. data/lib/pikuri/agent/configurator.rb +46 -106
  5. data/lib/pikuri/agent/context_window_detector.rb +44 -85
  6. data/lib/pikuri/agent/control/cancellable.rb +87 -66
  7. data/lib/pikuri/agent/control/interloper.rb +127 -105
  8. data/lib/pikuri/agent/control/step_limit.rb +25 -41
  9. data/lib/pikuri/agent/control.rb +14 -34
  10. data/lib/pikuri/agent/event.rb +123 -188
  11. data/lib/pikuri/agent/extension.rb +118 -94
  12. data/lib/pikuri/agent/extension_context.rb +50 -77
  13. data/lib/pikuri/agent/history.rb +653 -0
  14. data/lib/pikuri/agent/listener/rate_limited.rb +40 -66
  15. data/lib/pikuri/agent/listener/terminal.rb +143 -117
  16. data/lib/pikuri/agent/listener/token_log.rb +101 -140
  17. data/lib/pikuri/agent/listener.rb +23 -43
  18. data/lib/pikuri/agent/listener_list.rb +26 -47
  19. data/lib/pikuri/agent/synthesizer.rb +45 -87
  20. data/lib/pikuri/agent.rb +816 -474
  21. data/lib/pikuri/bundler_env.rb +68 -0
  22. data/lib/pikuri/extractor/html.rb +63 -110
  23. data/lib/pikuri/extractor/passthrough.rb +20 -30
  24. data/lib/pikuri/extractor.rb +93 -154
  25. data/lib/pikuri/file_type.rb +63 -135
  26. data/lib/pikuri/finalizers.rb +32 -47
  27. data/lib/pikuri/paths.rb +104 -13
  28. data/lib/pikuri/ruby_llm_patches.rb +106 -0
  29. data/lib/pikuri/sanitizer.rb +45 -67
  30. data/lib/pikuri/subprocess.rb +75 -119
  31. data/lib/pikuri/testing.rb +296 -0
  32. data/lib/pikuri/tool/calculator.rb +56 -66
  33. data/lib/pikuri/tool/execute_context.rb +42 -0
  34. data/lib/pikuri/tool/fetch.rb +51 -77
  35. data/lib/pikuri/tool/parameters.rb +21 -29
  36. data/lib/pikuri/tool/scraper.rb +55 -97
  37. data/lib/pikuri/tool/search/brave.rb +52 -80
  38. data/lib/pikuri/tool/search/duckduckgo.rb +59 -91
  39. data/lib/pikuri/tool/search/engines.rb +230 -97
  40. data/lib/pikuri/tool/search/exa.rb +56 -90
  41. data/lib/pikuri/tool/search/rate_limiter.rb +61 -38
  42. data/lib/pikuri/tool/search/result.rb +10 -15
  43. data/lib/pikuri/tool/trifecta_legs.rb +217 -0
  44. data/lib/pikuri/tool/web_scrape.rb +38 -54
  45. data/lib/pikuri/tool/web_search.rb +100 -24
  46. data/lib/pikuri/tool.rb +140 -65
  47. data/lib/pikuri/trifecta/contribution.rb +43 -0
  48. data/lib/pikuri/trifecta/node.rb +47 -0
  49. data/lib/pikuri/trifecta/report.rb +230 -0
  50. data/lib/pikuri/trifecta.rb +127 -0
  51. data/lib/pikuri/url_cache.rb +33 -49
  52. data/lib/pikuri/version.rb +1 -1
  53. data/lib/pikuri-core.rb +72 -88
  54. data/prompts/agent-loop.txt +5 -0
  55. data/prompts/pikuri-chat.txt +3 -12
  56. metadata +14 -3
@@ -1,54 +1,44 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pikuri
4
- # Process-global teardown registry: one +at_exit+ for the whole
5
- # process, with everything that owns a resource needing orderly
6
- # shutdown (agents, {VectorDb::Server::Chroma}, future background
7
- # workers) registering here instead of growing its own +at_exit+.
8
- # It is {Agent#on_close} promoted from per-agent to per-process —
9
- # the same LIFO + per-handler-rescue + idempotent shape, one level
10
- # up.
4
+ # Process-global teardown registry: one +at_exit+ for the whole process,
5
+ # with everything owning a resource that needs orderly shutdown (agents,
6
+ # {VectorDb::Server::Chroma}, background workers) registering here instead of
7
+ # growing its own +at_exit+. {Agent#on_close} promoted per-agent → per-process
8
+ # — the same LIFO + per-handler-rescue + idempotent shape.
11
9
  #
12
10
  # == Why one chokepoint
13
11
  #
14
- # Independent +at_exit+ hooks fire in an order decided by file load
15
- # order, which is invisible and fragile. Routing every teardown
16
- # through one registry makes the order explicit and controllable:
17
- # the SIGTERM-the-strays backstop ({Subprocess.cleanup!}) registers
18
- # at *load* time, so it sits at the bottom of the LIFO stack and runs
19
- # *last* — after agents and servers (which register at *construction*
20
- # time) have closed gracefully, while the subprocess machinery they
21
- # shell out to during close (e.g. {VectorDb::Server::Chroma#close}'s
22
- # +docker stop+) is still live.
12
+ # Independent +at_exit+ hooks fire in file-load order — invisible and fragile.
13
+ # One registry makes the order explicit: the SIGTERM-the-strays backstop
14
+ # ({Subprocess.cleanup!}) registers at *load* time, so it sits at the bottom
15
+ # of the LIFO stack and runs *last* — after agents and servers (registered at
16
+ # *construction* time) close gracefully, while the subprocess machinery they
17
+ # shell out to during close (a +docker stop+) is still live.
23
18
  #
24
19
  # == Contract
25
20
  #
26
- # A registrant MUST respond to +#close+, and +#close+ MUST be
27
- # idempotent and tolerant of running at process exit — the host may
28
- # also have closed it explicitly earlier. Pass a block instead for
29
- # teardown that has no natural +#close+ (e.g.
30
- # +Finalizers.register { Pikuri::Subprocess.cleanup! }+).
21
+ # A registrant MUST respond to +#close+, idempotent and tolerant of running at
22
+ # exit (the host may have closed it earlier). Pass a block instead for
23
+ # teardown with no natural +#close+
24
+ # (+Finalizers.register { Pikuri::Subprocess.cleanup! }+).
31
25
  #
32
26
  # == Order: LIFO
33
27
  #
34
- # Last registered, first closed — Ruby +ensure+ semantics. A
35
- # registrant that depends on an earlier one (a background indexer
36
- # writing into {VectorDb::Server::Chroma}) is registered later and so
37
- # tears down first. Registration order is therefore dependency order;
38
- # register the dependency before its dependents.
28
+ # Last registered, first closed. A registrant depending on an earlier one (a
29
+ # background indexer writing into {VectorDb::Server::Chroma}) registers later
30
+ # and tears down first — registration order is dependency order.
39
31
  #
40
32
  # == Errors are contained
41
33
  #
42
- # Each +#close+ runs inside its own +rescue+: a raise is logged via
43
- # {Pikuri.logger_for} and the sweep continues, so one botched
44
- # teardown can't strand the rest. {.run!} drains the registry, so a
45
- # second call (an explicit one, then the +at_exit+) closes nothing.
34
+ # Each +#close+ runs in its own +rescue+ (a raise is logged, the sweep
35
+ # continues). {.run!} drains the registry, so a second call closes nothing.
46
36
  module Finalizers
47
37
  # @return [Logger] subsystem logger for contained teardown failures.
48
38
  LOGGER = Pikuri.logger_for('Finalizers')
49
39
 
50
40
  # Adapts a teardown block to the +#close+ protocol, so a block and a
51
- # closeable object can share one registry.
41
+ # closeable share one registry.
52
42
  Closer = Struct.new(:block) do
53
43
  # @return [void]
54
44
  def close
@@ -60,17 +50,14 @@ module Pikuri
60
50
  @mutex = Mutex.new
61
51
 
62
52
  class << self
63
- # Register a closeable (or a block) to be torn down at process
64
- # exit. Returns the registered handle so the caller can later
65
- # {.unregister} it — a resource closed explicitly before exit
66
- # should drop out so it can be garbage-collected rather than
67
- # pinned alive until the process dies.
53
+ # Register a closeable (or a block) to tear down at exit. Returns the
54
+ # handle so the caller can later {.unregister} it — a resource closed
55
+ # explicitly before exit should drop out to be GC'd, not pinned alive.
68
56
  #
69
- # @param closeable [#close, nil] resource to close at exit; omit
70
- # when passing a block
71
- # @yield teardown to run at exit, for resources with no +#close+
72
- # @return [#close] the registered handle — the object itself, or
73
- # the {Closer} wrapping the block; pass it to {.unregister}
57
+ # @param closeable [#close, nil] resource to close; omit when passing a block
58
+ # @yield teardown for resources with no +#close+
59
+ # @return [#close] the registered handle (the object, or the {Closer}
60
+ # wrapping the block); pass to {.unregister}
74
61
  # @raise [ArgumentError] if neither an object nor a block is given
75
62
  def register(closeable = nil, &block)
76
63
  unless closeable || block
@@ -82,8 +69,7 @@ module Pikuri
82
69
  handle
83
70
  end
84
71
 
85
- # Drop a previously-registered handle. Idempotent — unregistering
86
- # something already gone (or never registered) is a no-op.
72
+ # Drop a previously-registered handle. Idempotent.
87
73
  #
88
74
  # @param handle [#close] the value returned by {.register}
89
75
  # @return [void]
@@ -92,10 +78,9 @@ module Pikuri
92
78
  nil
93
79
  end
94
80
 
95
- # Close every registrant in LIFO order, each guarded by its own
96
- # +rescue+. Wired to +at_exit+ at the bottom of this file.
97
- # Draining the registry under the lock makes a repeat call a
98
- # no-op and keeps it safe against a concurrent caller.
81
+ # Close every registrant in LIFO order, each guarded by its own +rescue+.
82
+ # Wired to +at_exit+ below. Draining under the lock makes a repeat call a
83
+ # no-op and stays safe against a concurrent caller.
99
84
  #
100
85
  # @return [void]
101
86
  def run!
data/lib/pikuri/paths.rb CHANGED
@@ -1,23 +1,35 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'pathname'
4
+ require 'tmpdir'
5
+ require 'fileutils'
4
6
 
5
7
  module Pikuri
6
- # Standardized on-disk locations for pikuri's local state. Centralizes
7
- # the XDG resolution so every component that caches to disk roots under
8
- # one place instead of each re-deriving it — currently
9
- # {Pikuri::VectorDb::Server::Chroma} (its corpus volume) and
10
- # {Pikuri::Memory::Mem0Server} (its mem0 checkout + data volume).
8
+ # Standardized on-disk locations for pikuri's local state, centralizing XDG
9
+ # resolution so every disk-caching component roots under one place instead of
10
+ # re-deriving it. The load-bearing distinction is {.cache} (shared across all
11
+ # pikuri processes, persistent) vs. {.new_temp} (private to this process,
12
+ # ephemeral) — reach for the wrong one and two concurrent processes clobber
13
+ # each other's scratch.
11
14
  module Paths
12
- # Pikuri's cache root: +$XDG_CACHE_HOME/pikuri+ when +XDG_CACHE_HOME+
13
- # is set and non-empty, else +~/.cache/pikuri+.
15
+ # +temp-*+ umbrellas older than this are reaped by {.sweep_stale_temps!} at
16
+ # core load — generous enough not to disturb a long-lived process in another
17
+ # shell, tight enough that one killed last week doesn't leak forever.
14
18
  #
15
- # A method, not a frozen constant, on purpose: a constant would
16
- # snapshot +XDG_CACHE_HOME+ at +require+ time, which breaks
17
- # env-stubbing in tests and ignores a runtime change in a long-lived
18
- # process. The directory is *not* created — callers +mkdir_p+ the
19
- # subdirectory they need (e.g. +Paths.cache.join('chroma')+,
20
- # +Paths.cache.join('mem0')+).
19
+ # @return [Integer] seconds
20
+ TEMP_STALE_SECONDS = 7 * 24 * 60 * 60
21
+
22
+ # Pikuri's cache root: +$XDG_CACHE_HOME/pikuri+ or +~/.cache/pikuri+.
23
+ #
24
+ # Shared across ALL pikuri processes and PERSISTENT (survives exit) — the
25
+ # home for state meant to be reused: the URL cache, mem0 / Qdrant / Chroma
26
+ # server data. For scratch private to *this* process that should vanish when
27
+ # it exits, use {.new_temp}; reaching for +cache+ + a fixed subdir is how two
28
+ # concurrent processes end up overwriting each other's files.
29
+ #
30
+ # A method, not a constant: a constant would snapshot +XDG_CACHE_HOME+ at
31
+ # +require+ time, breaking env-stubbing in tests and a runtime change. Not
32
+ # created — callers +mkdir_p+ the subdir they need.
21
33
  #
22
34
  # @return [Pathname] the +<cache home>/pikuri+ directory
23
35
  def self.cache
@@ -25,5 +37,84 @@ module Pikuri
25
37
  home = File.expand_path('~/.cache') if home.nil? || home.empty?
26
38
  Pathname.new(home).join('pikuri')
27
39
  end
40
+
41
+ # A fresh scratch dir private to this process, reaped at exit via
42
+ # {Pikuri::Finalizers} (and stale-swept by {.sweep_stale_temps!} for strays a
43
+ # crash leaves behind). Mint one per concurrent-safe scratch need and hold
44
+ # the result:
45
+ #
46
+ # dir = Paths.new_temp # ~/.cache/pikuri/temp-XXXX/
47
+ # snap = Snapshot.new(source: db, dir: dir) # can't collide with another process
48
+ #
49
+ # Minted IN HOME under {.cache} — never +/tmp+ — for two concrete reasons: a
50
+ # +cp --reflink=auto+ copy shares extents only on the source's filesystem
51
+ # (the copied DB lives under +$HOME+), and the bubblewrap sandbox binds its
52
+ # scratch at +/tmp+ inside, so an umbrella already under +/tmp+ would bind on
53
+ # top of itself.
54
+ #
55
+ # A factory — a *new* dir each call (hence +new_+, not a memoized accessor
56
+ # like {.cache}); a caller memoizes if it wants one stable dir, but per
57
+ # *consumer*, not per process. Memoizing one at process scope and handing it
58
+ # to several agents puts two writers on one path — the collision this exists
59
+ # to prevent, moved inside the process.
60
+ #
61
+ # @return [Pathname] absolute, symlink-resolved path of the freshly-made dir
62
+ def self.new_temp
63
+ FileUtils.mkdir_p(cache)
64
+ path = Pathname.new(Dir.mktmpdir('temp-', cache)).realpath
65
+ Pikuri::Finalizers.register { FileUtils.remove_entry(path.to_s) if path.exist? }
66
+ path
67
+ end
68
+
69
+ # Reap +temp-*+ umbrellas under {.cache} older than {TEMP_STALE_SECONDS}.
70
+ # Called once at core load (so every binary sweeps, regardless of which gems
71
+ # it wires) — the net for a process killed before its {Pikuri::Finalizers}
72
+ # reap could fire. Failures are swallowed (best-effort; the Finalizers
73
+ # removal is the load-bearing path), and recent dirs are left for concurrent
74
+ # processes.
75
+ #
76
+ # @return [void]
77
+ def self.sweep_stale_temps!
78
+ return unless File.directory?(cache)
79
+
80
+ cutoff = Time.now - TEMP_STALE_SECONDS
81
+ Dir.children(cache).each do |entry|
82
+ next unless entry.start_with?('temp-')
83
+
84
+ path = File.join(cache, entry)
85
+ next unless File.directory?(path)
86
+ next if File.mtime(path) > cutoff
87
+
88
+ FileUtils.remove_entry(path)
89
+ rescue StandardError
90
+ # best-effort sweep; never block the host on dead state
91
+ end
92
+ end
93
+
94
+ # Pikuri's config root: +$XDG_CONFIG_HOME/pikuri+ or +~/.config/pikuri+.
95
+ # Home for user-owned, hand-edited config (the OS helper's +MACHINE.md+).
96
+ #
97
+ # A method for the same reason as {.cache}. Not created — config is
98
+ # user-owned, so callers +mkdir_p+ only when writing.
99
+ #
100
+ # @return [Pathname] the +<config home>/pikuri+ directory
101
+ def self.config
102
+ home = ENV['XDG_CONFIG_HOME']
103
+ home = File.expand_path('~/.config') if home.nil? || home.empty?
104
+ Pathname.new(home).join('pikuri')
105
+ end
106
+
107
+ # Pikuri's state root: +$XDG_STATE_HOME/pikuri+ or +~/.local/state/pikuri+.
108
+ # Created automatically. For state that persists across restarts but isn't
109
+ # portable enough for XDG_DATA_HOME (logs, history, layout).
110
+ #
111
+ # @return [Pathname] the +<state home>/pikuri+ directory
112
+ def self.state
113
+ state = ENV['XDG_STATE_HOME']
114
+ state = File.expand_path('~/.local/state') if state.nil? || state.empty?
115
+ state = Pathname.new(state).join('pikuri')
116
+ FileUtils.mkdir_p(state.to_path)
117
+ state
118
+ end
28
119
  end
29
120
  end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ruby_llm'
4
+
5
+ module Pikuri
6
+ # Local corrections to ruby_llm, applied at load time, carried until the fix
7
+ # ships upstream. Each patch pins us to a ruby_llm internal's private shape, so
8
+ # it's a liability: small, self-contained, tied to a tracked upstream issue so
9
+ # we know when to delete it. Reopening a third-party class is last resort —
10
+ # here only because the defect is on the live chat path and the release cadence
11
+ # isn't ours. Patching rather than dropping the dependency is the standing
12
+ # policy — `DECISIONS.md` +D_keep_ruby_llm+.
13
+ #
14
+ # == The streaming error-parse crash (ruby_llm #837)
15
+ #
16
+ # {https://github.com/crmne/ruby_llm/issues/837}
17
+ #
18
+ # On a *streaming* turn, a non-200 response whose body parses to a non-Hash
19
+ # JSON value crashes ruby_llm 1.16.0 with +TypeError: String does not have
20
+ # #dig+ at +providers/openai/streaming.rb:43+, instead of surfacing the
21
+ # endpoint's error. Two bodies trigger it, both because the method assumes a
22
+ # nested object to +.dig+:
23
+ #
24
+ # 1. A bare JSON string. The guard +return unless error_data['error']+ is
25
+ # meant as key-presence, but on a +String+ that's substring matching, so a
26
+ # body containing "error" slips through into +.dig+.
27
+ # 2. A +{"error": "<message>"}+ body where +error+ is a string value
28
+ # (xAI/Grok), not an object: the guard passes, then
29
+ # +error_data.dig('error', 'type')+ calls +String#dig+ — same +TypeError+.
30
+ #
31
+ # The non-streaming path already handles both (cases on Hash/Array/else,
32
+ # short-circuits a string +error+). This mirrors that: never +.dig+ a non-Hash,
33
+ # hand a non-typed error up as the message with a +nil+ status so ruby_llm's
34
+ # +build_stream_error_response+ falls back to the real HTTP status.
35
+ #
36
+ # Redefined whole (not +prepend+ + +super+) because it's a small self-contained
37
+ # +module_function+ with no state — faithful reproduction plus the guard is
38
+ # clearer than wrapping the buggy original. Re-running +module_function+
39
+ # replaces both the module method and the private copy the provider gets via
40
+ # +include+.
41
+ #
42
+ # == When to delete this file
43
+ #
44
+ # ruby_llm main already carries an equivalent fix, in a module renamed to
45
+ # +RubyLLM::Protocols::ChatCompletions::Streaming+ — so the next release past
46
+ # 1.16.0 obsoletes the patch *and* moves its target. The load-time guard below
47
+ # turns that into a boot failure naming this file; delete it and its spec then.
48
+ module RubyLlmPatches
49
+ module_function
50
+
51
+ # @return [Boolean] +true+ once the patches below have been applied; a
52
+ # literal, kept honest by the load-time guard that raises if the target
53
+ # module has moved out from under it
54
+ def applied? = true
55
+ end
56
+ end
57
+
58
+ # Reopening a module that has moved silently defines a fresh empty one: the
59
+ # patch would stop applying, the bug would return, and nothing — not even
60
+ # {Pikuri::RubyLlmPatches.applied?} — would notice. Fail at boot instead.
61
+ unless defined?(RubyLLM::Providers::OpenAI::Streaming) &&
62
+ RubyLLM::Providers::OpenAI::Streaming.respond_to?(:parse_streaming_error)
63
+ raise "RubyLLM::Providers::OpenAI::Streaming.parse_streaming_error is gone from ruby_llm " \
64
+ "#{RubyLLM::VERSION} — the streaming error-parse patch no longer applies. It is fixed " \
65
+ 'upstream: delete pikuri-core/lib/pikuri/ruby_llm_patches.rb and its spec (ruby_llm #837).'
66
+ end
67
+
68
+ module RubyLLM
69
+ module Providers
70
+ class OpenAI
71
+ module Streaming
72
+ # Parse a streaming error body into a +[status, message]+ pair,
73
+ # tolerating a non-Hash parsed body. See
74
+ # {Pikuri::RubyLlmPatches} for why this override exists and when
75
+ # to remove it (ruby_llm #837).
76
+ #
77
+ # @param data [String] the JSON error body as received
78
+ # @return [Array(Integer, nil), String] a +[status, message]+
79
+ # pair; +status+ is +nil+ when the body carries no error type,
80
+ # deferring classification to the response's HTTP status
81
+ def parse_streaming_error(data)
82
+ error_data = JSON.parse(data)
83
+ # Non-Hash body (a bare JSON string/number/array): no structured
84
+ # error to read — surface it as the message, defer status.
85
+ return [nil, error_data.to_s] unless error_data.is_a?(Hash)
86
+
87
+ error = error_data['error']
88
+ return [nil, error_data.to_s] unless error
89
+ # A string error value (xAI/Grok's {"error": "..."}) has no type
90
+ # field — hand it up as-is, defer status to the HTTP response.
91
+ return [nil, error] if error.is_a?(String)
92
+
93
+ case error['type']
94
+ when 'server_error'
95
+ [500, error['message']]
96
+ when 'rate_limit_exceeded', 'insufficient_quota'
97
+ [429, error['message']]
98
+ else
99
+ [400, error['message']]
100
+ end
101
+ end
102
+ module_function :parse_streaming_error
103
+ end
104
+ end
105
+ end
106
+ end
@@ -1,73 +1,53 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pikuri
4
- # Renders attacker-controlled text safe to display, and reports *why*
5
- # it was unsafe.
4
+ # Renders attacker-controlled text safe to display, and reports *why* it was
5
+ # unsafe.
6
6
  #
7
- # Every string an LLM composes is untrusted: a bash command, a tool
8
- # observation echoed back to the user, a description it wrote for a
9
- # confirmation prompt. A model that is broken — or, far more likely,
10
- # being driven by a prompt injection — can embed bytes that a terminal
11
- # acts on rather than prints: a carriage return that overwrites the
12
- # line the user just read, an ESC that recolors or repositions, a
13
- # backspace that erases, a bidirectional override that reorders text so
14
- # it reads differently than it runs, a zero-width character that hides
15
- # in plain sight, or a Cyrillic +а+ masquerading as a Latin +a+. The
16
- # whole point of a confirmation prompt collapses if the bytes the user
17
- # approves are not the bytes that execute.
7
+ # Every string an LLM composes is untrusted (a bash command, a tool
8
+ # observation echoed to the user, a confirmation-prompt description). A model
9
+ # — usually one being prompt-injected — can embed bytes a terminal *acts on*
10
+ # rather than prints: a CR that overwrites the line just read, an ESC that
11
+ # recolors/repositions, a backspace that erases, a bidi override that reorders
12
+ # text so it reads differently than it runs, a zero-width char, or a Cyrillic
13
+ # +а+ posing as Latin +a+. A confirmation prompt is worthless if the bytes the
14
+ # user approves aren't the bytes that execute.
18
15
  #
19
- # {.sanitize} is the one chrome-independent primitive every renderer
20
- # (terminal, TUI, web) routes through. It does two things and returns
21
- # both as a {Result}:
16
+ # {.sanitize} is the one chrome-independent primitive every renderer routes
17
+ # through, returning a {Result}:
22
18
  #
23
- # 1. *Neutralize* — make the dangerous bytes visible without changing
24
- # structure. Control bytes become +\xNN+, bidi/zero-width codepoints
25
- # become +\u{NNNN}+, tab becomes +\t+. Newlines are preserved
26
- # (multi-line commands are normal). This is *faithful, not
27
- # beautifying*: it never collapses runs of whitespace or rewrites a
28
- # tab to a space, because the user must see exactly what they are
29
- # approving — a Makefile's leading tab stays visibly a tab. A web
30
- # chrome composes +html_escape(sanitize(s).text)+; the HTML layer is
31
- # the caller's, not ours.
32
- # 2. *Warn* — return a {Warning} per category detected, each a semantic
33
- # record (kind + offending tokens + a plain-English explanation).
34
- # Presentation is the chrome's: a terminal renders these bold yellow,
35
- # a web client a banner. The {Warning} carries no color or markup.
19
+ # 1. *Neutralize* — make dangerous bytes visible without changing structure:
20
+ # control bytes → +\xNN+, bidi/zero-width → +\u{NNNN}+, tab → +\t+;
21
+ # newlines preserved. *Faithful, not beautifying* — it never collapses
22
+ # whitespace or rewrites a tab, because the user must see exactly what they
23
+ # approve (a Makefile's leading tab stays a tab). A web chrome adds
24
+ # +html_escape+ on top; the HTML layer is the caller's.
25
+ # 2. *Warn* — one {Warning} per category detected (kind + offending tokens +
26
+ # plain-English explanation). Presentation is the chrome's.
36
27
  #
37
28
  # == Scope (deliberately closed)
38
29
  #
39
- # Detection covers the *invisibility / cursor-control / reordering*
40
- # attack classes completely, because each is a finite, enumerable set
41
- # of codepoints: C0 controls, C1 controls (a second ANSI introducer on
42
- # some emulators), DEL, the bidi overrides, and the zero-width
43
- # characters. On top of that, {.sanitize} flags *mixed-script tokens* —
44
- # a single word combining letters from Latin + Cyrillic + Greek, which
45
- # is the signature of a homoglyph spoof and has near-zero false
46
- # positives on real text (humans do not weld two alphabets inside one
47
- # word; +café+ is all-Latin, +Москва+ all-Cyrillic, only +Pаypal+ mixes).
30
+ # Detection is complete on the *invisibility / cursor-control / reordering*
31
+ # classes, each a finite codepoint set: C0/C1 controls, DEL, bidi overrides,
32
+ # zero-width chars. Plus *mixed-script tokens* — one word welding Latin +
33
+ # Cyrillic + Greek, the homoglyph-spoof signature, near-zero false positives
34
+ # (+café+ all-Latin, +Москва+ all-Cyrillic, only +Pаypal+ mixes).
48
35
  #
49
- # Two confusable classes are explicitly *out of scope*, because
50
- # detecting them needs Unicode confusables tables and produces heavy
51
- # false positives on legitimate multilingual text:
52
- #
53
- # * *Whole-script* homoglyphs — an entirely-Cyrillic string that merely
54
- # looks Latin (no mixing to detect).
55
- # * *Single-symbol* confusables — the Greek question mark +;+ (U+037E)
56
- # that looks like a semicolon, full-width forms, the division slash.
57
- #
58
- # "Solid" here means complete on the classes above, not exhaustive over
59
- # all of Unicode.
36
+ # Two confusable classes are *out of scope* — detecting them needs Unicode
37
+ # confusables tables and heavy false positives on legitimate multilingual
38
+ # text: *whole-script* homoglyphs (an all-Cyrillic string that looks Latin,
39
+ # no mixing to detect) and *single-symbol* confusables (the Greek +;+ U+037E,
40
+ # full-width forms). "Solid" means complete on the classes above, not
41
+ # exhaustive over Unicode.
60
42
  module Sanitizer
61
- # One reason a piece of text was flagged, ready for a chrome to
62
- # render however it surfaces warnings (bold yellow line, web banner).
43
+ # One reason a piece of text was flagged, for a chrome to render its own way.
63
44
  #
64
- # * +kind+ — a {Symbol} category: +:backspace+, +:control_bytes+,
65
- # +:bidi+, +:zero_width+, or +:mixed_script+.
66
- # * +offenders+ — the distinct offending tokens, in first-seen order:
67
- # the escaped forms (+"\\x1b"+, +"\\u{202e}"+) for byte categories,
68
- # the raw tokens (+"Pаypal"+) for +:mixed_script+.
69
- # * +explanation+ — a one-line, chrome-agnostic English summary of
70
- # what the bytes can do.
45
+ # * +kind+ — a {Symbol}: +:backspace+, +:control_bytes+, +:bidi+,
46
+ # +:zero_width+, or +:mixed_script+.
47
+ # * +offenders+ — the distinct offending tokens, first-seen order: escaped
48
+ # forms (+"\\x1b"+, +"\\u{202e}"+) for byte categories, raw tokens
49
+ # (+"Pаypal"+) for +:mixed_script+.
50
+ # * +explanation+ — a one-line chrome-agnostic summary.
71
51
  Warning = Data.define(:kind, :offenders, :explanation)
72
52
 
73
53
  # The output of {Sanitizer.sanitize}.
@@ -84,17 +64,15 @@ module Pikuri
84
64
  # zero-width no-break space.
85
65
  ZERO_WIDTH = [0x200b, 0x200c, 0x200d, 0xfeff].freeze
86
66
 
87
- # Codepoints {.sanitize} rewrites: C0 controls including tab (U+0009)
88
- # but *excluding* newline (U+000A, which passes through untouched),
89
- # C1 controls + DEL (U+007F–009F), the zero-width set, and the bidi
90
- # overrides. Newline is the one control character a faithful render
91
- # must keep, so the C0 range is split around it.
67
+ # Codepoints {.sanitize} rewrites: C0 controls including tab (U+0009) but
68
+ # *excluding* newline (U+000A, passes through), C1 + DEL (U+007F–009F), the
69
+ # zero-width set, and the bidi overrides. Newline is the one control a
70
+ # faithful render keeps, so the C0 range splits around it.
92
71
  SUSPECT = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f\u200b-\u200d\u202a-\u202e\u2066-\u2069\ufeff]/
93
72
 
94
- # The three Latin-confusable scripts whose mixing inside one token
95
- # signals a homoglyph spoof. Punctuation, digits and spaces are the
96
- # +Common+ script and match none of these, so they never count toward
97
- # the "two distinct scripts" threshold.
73
+ # The three Latin-confusable scripts whose mixing inside one token signals a
74
+ # homoglyph spoof. Punctuation/digits/spaces are +Common+ and match none, so
75
+ # they never count toward the two-script threshold.
98
76
  CONFUSABLE_SCRIPTS = { 'Latin' => /\p{Latin}/, 'Cyrillic' => /\p{Cyrillic}/, 'Greek' => /\p{Greek}/ }.freeze
99
77
 
100
78
  # Neutralize +text+ for literal display and report what was flagged.