Module: Terret::Tools::AllowList

Defined in:
lib/terret/tools.rb

Overview

Deny-by-default allow list (plan §6.3), hot-reloadable (§12 M6): the ACTIVE pattern set is the last durable policy/updated event in the call's session, falling back to the install-time patterns as the floor. update is an ordinary durable append — it takes effect on the very next call with no reinstall, and replay rebuilds it, so a hot-reloaded policy survives a restart while the floor only governs sessions that never updated. Patterns are File.fnmatch globs; matching is case-sensitive and "*" does not match dotfiles — both fail closed.

Class Method Summary collapse

Class Method Details

.active_patterns(ctx, session_id, cache) ⇒ Object

Read-through cache over the log projection. A hit returns the cached patterns-or-nil without touching the log; a miss derives once and stores the result. Concurrency: under the fiber scheduler a fiber yields only at an await, and neither this read/write nor the session/event writer awaits between touching the Hash — so same-sid operations cannot interleave and distinct sids are independent; a plain Hash needs no lock. An unknown session raises KeyError out of the derivation before any scan and before anything is stored, so it is NOT cached: the call re-derives (and re-warns) each time, and a deny-all never ossifies into an allow.



276
277
278
279
280
281
# File 'lib/terret/tools.rb', line 276

def self.active_patterns(ctx, session_id, cache)
  cache.fetch(session_id) { cache[session_id] = current_patterns(ctx, session_id) }
rescue KeyError
  warn "terret: no policy readable for session #{session_id.inspect}; denying every tool call"
  []
end

.admits?(ctx, call, floor, cache) ⇒ Boolean

The shared decision, used by both the per-agent listener and the floor gate: does the ACTIVE policy for this call's session admit its tool name? Patterns are File.fnmatch globs; matching is case-sensitive and "*" does not match dotfiles — both fail closed.

Returns:

  • (Boolean)


225
226
227
228
# File 'lib/terret/tools.rb', line 225

def self.admits?(ctx, call, floor, cache)
  active = active_patterns(ctx, call.session_id, cache) || floor
  active.any? { |p| File.fnmatch(p, call.name) }
end

.current_patterns(ctx, session_id) ⇒ Object

The pure log derivation the cache reads through: the patterns of the last durable policy/updated in the session, or nil if it never updated. Raises KeyError for a session this context cannot read (handled in active_patterns), which is why the rescue lives there and not here.



287
288
289
290
291
# File 'lib/terret/tools.rb', line 287

def self.current_patterns(ctx, session_id)
  ctx[:sessions].fetch(session_id).events.reverse_each
                .find { |e| e.type == "policy/updated" }
                &.payload&.[](:patterns)
end

.install(ctx, patterns) ⇒ Object

A per-agent (or per-context) allow list as a tools/pre_execute listener. This is the right shape for an agent's OWN policy: it rides the agent's fork and can only make the effective policy STRICTER (a veto here stops the call). It is deliberately NOT the authoritative floor — a listener is a peer another listener can order itself ahead of. For the deny-by-default floor that no row's listener may bypass, see #install_floor.



177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
# File 'lib/terret/tools.rb', line 177

def self.install(ctx, patterns)
  floor = Array(patterns).map(&:to_s)
  cache = new_cache
  pre = ctx.on("tools/pre_execute") do |call, next_|
    if admits?(ctx, call, floor, cache)
      next_.(call)
    else
      Veto.new(reason: "#{call.name} is not on the allow list")
    end
  end
  inval = install_invalidation(ctx, cache)

  # Composite: tear the gate and its invalidation down together. Both are
  # already recorded as effects of this context (so fork.dispose! reaps
  # them); this is the handle a caller pulls to remove its list early.
  lambda do
    pre.call
    inval.call
  end
end

.install_floor(ctx, patterns) ⇒ Object

The authoritative deny-by-default floor (docs/composition.md §6, docs/security.md). It runs the SAME per-session, hot-reloadable decision as #install, but wired into ctx as the Registry's floor gate rather than as a tools/pre_execute listener. That placement is the whole point: the floor mounts in a later loader pass than a no-inject row, so as a listener it sat BEHIND that row's listener in the waterfall and a listener that admitted a call without delegating short-circuited past it. As the gate, it runs after the waterfall on the call that will actually execute, so no listener any row registers can bypass it.



207
208
209
210
211
212
213
214
215
216
217
218
219
# File 'lib/terret/tools.rb', line 207

def self.install_floor(ctx, patterns)
  floor = Array(patterns).map(&:to_s)
  cache = new_cache
  gate = ctx[:tools].install_floor(ctx) do |call|
    Veto.new(reason: "#{call.name} is not on the allow list") unless admits?(ctx, call, floor, cache)
  end
  inval = install_invalidation(ctx, cache)

  lambda do
    gate.call
    inval.call
  end
end

.install_invalidation(ctx, cache) ⇒ Object

Log-first invalidation. The cache is a read-through of the durable log, never a second source of truth, so the ONLY write besides a miss is a policy/updated landing in the log. session/event is emitted on the context that mounts Sessions — the root of the fork chain, NOT a forked ctx — and a fork-registered listener would never see it, so we listen on root. Lifetime still follows the caller: wrapping root.on in ctx.effect records the teardown as an effect of THIS context, so disposing the agent (Loop#dispose_agent -> fork.dispose!) reaps the root listener too, and it also rides the composite disposer the callers return. Fan-out is synchronous and in seq order, so update's append has refreshed the entry before the next call reads it.



250
251
252
253
254
255
256
257
258
# File 'lib/terret/tools.rb', line 250

def self.install_invalidation(ctx, cache)
  root = ctx
  root = root.parent while root.parent
  ctx.effect do
    root.on("session/event") do |ev|
      cache[ev.session_id] = ev.payload[:patterns] if ev.type == "policy/updated"
    end
  end
end

.new_cacheObject

Per-install, never global: a fresh cache is a closure local of THIS install, so a forked agent scope, a hot policy swap, and the floor each get their own. Two installs sharing one would leak one agent's policy into another's — the cross-agent bleed this milestone closed. Keyed by session id; the value is the patterns from that session's last policy/updated, or nil for "no policy yet, fall to the floor" (nil is cached too, so a never-updated session also stops rescanning the log).



237
# File 'lib/terret/tools.rb', line 237

def self.new_cache = {}

.update(ctx, session_id, patterns) ⇒ Object

Hot update: durable, per-session, last one wins.



261
262
263
264
# File 'lib/terret/tools.rb', line 261

def self.update(ctx, session_id, patterns)
  ctx[:sessions].append(session_id, "policy/updated",
                        { patterns: Array(patterns).map(&:to_s) })
end