Class: Rubino::Run::SessionApprovalCache

Inherits:
Object
  • Object
show all
Defined in:
lib/rubino/run/session_approval_cache.rb

Overview

Remembers approval decisions that should survive past the current call so the agent doesn’t re-prompt the user for the same operation in the same session.

Granularity: a decision is stored as a DERIVED RULE, not the exact “<tool>:<command>” string. The caller still passes a scope shaped like “shell:rm -rf /tmp/cache” or “write:report.md”; the cache splits it into (tool, command) and asks Security::PrefixDeriver for the rule to remember. This mirrors the reference, which keys session approvals on a PATTERN KEY rather than the raw command (approve_session / is_approved). The practical effect for S3:

- a DANGEROUS command remembers its pattern CLASS, so approving e.g.
  `git push --force origin main` once also covers `git push -f other`
  in the same session (same "git force push" class);
- a PLAIN command still remembers only the exact command, so approving
  `git status` does NOT auto-approve `git diff` (narrow for S3; the
  broad prefix rule is derived but wired into a decision only in S5).

A scope with no “:” (a tool-wide scope like “shell”) has no command to derive from and is stored/matched verbatim.

Persistence: in-memory, process-lifetime. “session” decisions die with the process; “always” would deserve disk persistence but isn’t wired up yet (S5), so we treat both as session-scoped for now.

Thread-safe: every read/write goes through @mutex.

Constant Summary collapse

REMEMBERED_DECISIONS =

Decisions that should be persisted on approval.

%w[session always].freeze

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initializeSessionApprovalCache

Returns a new instance of SessionApprovalCache.



49
50
51
52
# File 'lib/rubino/run/session_approval_cache.rb', line 49

def initialize
  @data = Hash.new { |h, k| h[k] = [] } # session_id => [Rule, ...]
  @mutex = Mutex.new
end

Class Method Details

.instanceObject

Singleton accessor. We don’t use Dry::Container or similar here because the cache is process-global state that the runner needs to inject into per-run UI::API instances; one shared object is the simplest expression of “remember across runs of the same session”.



36
37
38
# File 'lib/rubino/run/session_approval_cache.rb', line 36

def self.instance
  @instance ||= new
end

.reset_singleton!Object

Resets the singleton — used by tests that need a clean slate. Avoids hidden cross-test leakage when specs share the process.



42
43
44
# File 'lib/rubino/run/session_approval_cache.rb', line 42

def self.reset_singleton!
  @instance = nil
end

Instance Method Details

#allowed?(session_id, scope) ⇒ Boolean

True when a prior decision for this session already covers the command carried by ‘scope` — pattern-class membership, prefix start_with?, or an exact-command match, per the stored rule kinds.

Returns:

  • (Boolean)


70
71
72
73
74
75
76
77
# File 'lib/rubino/run/session_approval_cache.rb', line 70

def allowed?(session_id, scope)
  return false unless session_id && scope

  command = scope_command(scope)
  @mutex.synchronize do
    @data[session_id.to_s].any? { |rule| rule.covers?(command) }
  end
end

#forget!(session_id = nil) ⇒ Object

Drops every cached decision for one session (e.g. after a session is deleted). Pass nil to wipe every session.



81
82
83
84
85
86
87
88
89
# File 'lib/rubino/run/session_approval_cache.rb', line 81

def forget!(session_id = nil)
  @mutex.synchronize do
    if session_id
      @data.delete(session_id.to_s)
    else
      @data.clear
    end
  end
end

#remember(session_id, scope, decision) ⇒ Object

Records a decision for (session_id, scope) as a derived rule. No-op when either value is blank, or the decision isn’t a remembered kind.



56
57
58
59
60
61
62
63
64
65
# File 'lib/rubino/run/session_approval_cache.rb', line 56

def remember(session_id, scope, decision)
  return unless session_id && scope
  return unless REMEMBERED_DECISIONS.include?(decision.to_s.downcase)

  rule = rule_for_scope(scope)
  @mutex.synchronize do
    rules = @data[session_id.to_s]
    rules << rule unless rules.any? { |r| r == rule }
  end
end