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
-
.active_patterns(ctx, session_id, cache) ⇒ Object
Read-through cache over the log projection.
-
.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.
-
.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.
-
.install(ctx, patterns) ⇒ Object
A per-agent (or per-context) allow list as a tools/pre_execute listener.
-
.install_floor(ctx, patterns) ⇒ Object
The authoritative deny-by-default floor (docs/composition.md §6, docs/security.md).
-
.install_invalidation(ctx, cache) ⇒ Object
Log-first invalidation.
-
.new_cache ⇒ Object
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.
-
.update(ctx, session_id, patterns) ⇒ Object
Hot update: durable, per-session, last one wins.
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.
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_cache ⇒ Object
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 |