terret-core 0.1.0 → 0.1.1

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: fb6dbfe91797674f464068e08da10eb0d19289dc30d3a791c5c46d9450c9ff13
4
- data.tar.gz: fa2cabe0f12d5fe1a94d9d14cb22686f9e6208dfd9b5a69316124ee1bf162ee5
3
+ metadata.gz: f36dbce70ed5b476d44ca902a7a261d7ae71c0f019109f9ec02052f8c7900714
4
+ data.tar.gz: c149aa846c4ea730562fa0e2f3e0539edc12ba533f468bac578f19617ef903ad
5
5
  SHA512:
6
- metadata.gz: c4cfee58e20514fdeb1e8f3d60e3336916c6c54e169377f1ff64ec1ba1d6373b6dc2a2a01c7f579ede66d0f49a92fc90151947910b9a4bfe5e895dca3ab4b426
7
- data.tar.gz: c250f6994e87dc668bd6cc60d9e967ff8f1232b972cb0be4853c4c0ba9a257012450424701d93fc13b39106e2dbcb63d3cddf55abccfa5a4ec2ed9bd31a8a2ba
6
+ metadata.gz: 83921151858e5edf9df27f6a7a6064d2936f5059fc504e6ffd27e469ed345c516504c88f7c7104309ba238063bf403a718d1cbbf33f64ea85313e656e618c3f5
7
+ data.tar.gz: 64f2129e36d07a74d1cbd4a0caf49763519992992398f634e2383c3d0439abb8c51119be93fbb4399c5823305ca97270f0884d1072d2cf9a2a20fdc1be15a30f
@@ -0,0 +1,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Terret
4
+ module Tools
5
+ # ctx[:approvals] — durable human-in-the-loop gating (plan §6.3, §12 M6).
6
+ # An execute-stage middleware parks calls whose definition demands a
7
+ # decision, appends durable approval/requested, and resumes when a
8
+ # matching approval/resolved lands in the log (the socket's approve/deny
9
+ # frames append exactly that). Both sides are durable, so a parked call
10
+ # survives a process death: on resume the gate finds the recorded verdict
11
+ # and never parks. It gates on tools/execute rather than tools/pre_execute
12
+ # so pre_execute vetoes (the per-agent AllowList) settle a call before a
13
+ # human is ever asked.
14
+ class Approvals < Hames::Service
15
+ service_key :approvals
16
+ inject :sessions, :tools, :loop
17
+ config_schema({}) # opt-in per tool; the service itself takes no config
18
+
19
+ def start(ctx)
20
+ @ctx = ctx
21
+ # A unique per-park token => { session_id:, call_id:, queue: }. Keyed by
22
+ # the token rather than by [session_id, call_id] because provider
23
+ # tool-call ids are NOT contractually unique: two calls of one parallel
24
+ # batch can arrive sharing an id, and keying by it let the second park
25
+ # overwrite the first's queue so a single verdict woke only the survivor
26
+ # and the first fiber parked forever, hanging the barrier. Each park now
27
+ # owns its own entry and its own wake. The DURABLE correlation
28
+ # (approval/requested and /resolved, matched in the log on call_id +
29
+ # name + args) is unchanged; only this in-memory map needed unique keys.
30
+ @waiting = {}
31
+ @waiting_mutex = Mutex.new # tests resolve from another thread; wake_one scans
32
+ @park_seq = 0
33
+
34
+ ctx.on("tools/execute") do |call, next_|
35
+ gate(call, next_)
36
+ end
37
+ ctx.on("session/event") do |ev|
38
+ next unless ev.type == "approval/resolved"
39
+
40
+ wake_one(ev.session_id, ev.payload[:call_id], ev.payload)
41
+ end
42
+ end
43
+
44
+ # Log-derived: requested without a matching resolved, within the OPEN
45
+ # turn. The in-memory waiter map is never consulted — after a restart it
46
+ # is empty while the log still knows what is owed. Nothing is pending
47
+ # once the turn that asked has closed: a request its turn outlived was
48
+ # settled by that turn ending, and a provider is free to reuse the call
49
+ # id afterwards.
50
+ def pending(session_id)
51
+ events = open_turn(session_id)
52
+ resolved = events.filter_map { |e| e.payload[:call_id] if e.type == "approval/resolved" }
53
+ events.filter_map do |e|
54
+ next unless e.type == "approval/requested"
55
+ next if resolved.include?(e.payload[:call_id])
56
+
57
+ e.payload[:call_id]
58
+ end
59
+ end
60
+
61
+ def pending?(session_id, call_id) = pending(session_id).include?(call_id)
62
+
63
+ # Cancel's escape hatch: deny everything parked for a session, durably.
64
+ # Each denial is an ordinary approval/resolved append, so an in-process
65
+ # parked fiber unparks through the same listener as a socket verdict,
66
+ # and a restart-orphaned request settles for good.
67
+ def deny_pending!(session_id, reason: "cancelled")
68
+ pending(session_id).each do |call_id|
69
+ @ctx[:sessions].append(session_id, "approval/resolved",
70
+ { call_id: call_id, verdict: "denied", reason: reason })
71
+ end
72
+ # The durable denials above wake one waiter per unique pending id
73
+ # through the resolved listener. A provider that reused a call id within
74
+ # one parallel batch parked more than one fiber on that id, though, and
75
+ # a cancel means all of them: sweep whatever is still parked for this
76
+ # session onto the denial they share, so no fiber is left holding the
77
+ # barrier open. Waiters the appends already woke are gone from the map,
78
+ # so this touches only the ones a single durable denial could not reach.
79
+ drain_session_waiters(session_id, { verdict: "denied", reason: reason })
80
+ end
81
+
82
+ private
83
+
84
+ def gate(call, next_)
85
+ d = begin
86
+ @ctx[:tools].fetch(call.name)
87
+ rescue KeyError
88
+ nil # vanished tool: fall through — the base renders its recoverable error
89
+ end
90
+ return next_.(call) unless d && requires_approval?(d)
91
+
92
+ # Order is the contract: a verdict already in the log settles the call
93
+ # however it got there, so a resume inside a child honors the decision
94
+ # a human really made. Only a call with no answer at all reaches the
95
+ # unattended check, and only then does it fail closed.
96
+ verdict = recorded_verdict(call) || unattended_verdict(call) || park(call)
97
+ if verdict[:verdict] == "approved"
98
+ next_.(call)
99
+ else
100
+ reason = verdict[:reason] || "no reason given"
101
+ Result.new(id: call.id, content: nil, error: "#{call.name} denied: #{reason}")
102
+ end
103
+ end
104
+
105
+ # Fail closed instead of deadlocking. A subagent's session cannot be
106
+ # reached by any approver: the parent's log never names it, so no socket
107
+ # is bound to it and no operator can answer a request they were never
108
+ # shown. Parking there would wait on a verdict that cannot arrive and
109
+ # would take the parent's turn — and the fiber running it — with it, so
110
+ # the call is denied with a reason the model can act on and report.
111
+ #
112
+ # Nothing durable is appended for the refusal. It is not a decision
113
+ # anybody made, and the tool/result the loop writes is already the
114
+ # permanent record of what happened to the call.
115
+ UNATTENDED_DENIAL = { verdict: "denied",
116
+ reason: "no approver can reach a subagent session" }.freeze
117
+
118
+ def unattended_verdict(call)
119
+ agent = @ctx[:loop].agent_for_session(call.session_id)
120
+ agent&.unattended ? UNATTENDED_DENIAL : nil
121
+ end
122
+
123
+ # :always asks every time; :policy asks when the tool mutates (plan
124
+ # §13's spirit: mutation is what needs a human under policy); :never
125
+ # (the default) passes through.
126
+ def requires_approval?(d)
127
+ d.approval == :always || (d.approval == :policy && d.mutating)
128
+ end
129
+
130
+ # Everything appended after the last turn/start, and nothing at all once
131
+ # that turn has closed. Approvals are per-turn state.
132
+ def open_turn(session_id)
133
+ events = @ctx[:sessions].fetch(session_id).events
134
+ opened = events.rindex { |e| e.type == "turn/start" }
135
+ return [] unless opened
136
+
137
+ turn = events[(opened + 1)..]
138
+ turn.any? { |e| e.type == "turn/end" } ? [] : turn
139
+ end
140
+
141
+ # A verdict already in the log (replay after a restart, or a decision
142
+ # that raced ahead of execution) settles the call without parking. The
143
+ # match is bound to content, not just to the call id: the latest request
144
+ # in the open turn naming this id, this tool, and these args, with a
145
+ # verdict for that id recorded after it. Provider tool call ids are not
146
+ # contractually unique, so an id reused in a later turn — or reused
147
+ # within one turn for a different call — must never inherit an old
148
+ # decision.
149
+ def recorded_verdict(call)
150
+ events = open_turn(call.session_id)
151
+ # Both compared in stored form, and hoisted out of the scan: a tool
152
+ # name is content like its args (the model chooses it), so a scrubber
153
+ # rewrites it on the way into the log and a raw comparison would never
154
+ # match again.
155
+ name = stored_form(call.name)
156
+ args = stored_form(call.args)
157
+ asked = events.rindex do |e|
158
+ e.type == "approval/requested" && e.payload[:call_id] == call.id &&
159
+ e.payload[:name] == name && e.payload[:args] == args
160
+ end
161
+ return nil unless asked
162
+
163
+ events[(asked + 1)..].find do |e|
164
+ e.type == "approval/resolved" && e.payload[:call_id] == call.id
165
+ end&.payload
166
+ rescue NonPrimitivePayload
167
+ # A value the log refuses has no stored form to compare against — a
168
+ # Time or some other object a plugin synthesized into args, which the
169
+ # JSON round trip this used to do coerced silently. That is a
170
+ # comparison this method cannot make, not a verdict it found, so it
171
+ # answers nil and the call parks. Park re-uses a standing request where
172
+ # there is one; with no standing request, park's own append of these
173
+ # same args raises there instead — which is what this path has always
174
+ # done with a value the log cannot store, and is left alone. Provider
175
+ # args are JSON primitives, so a model's own call never reaches here.
176
+ nil
177
+ end
178
+
179
+ # Args reach the log through Sessions' primitives contract (symbol keys,
180
+ # symbols in value position stringified) AND through any registered
181
+ # scrubber, so the comparison above is against what was really stored.
182
+ # Asking Sessions rather than round-tripping JSON here is what keeps a
183
+ # redacted argument from making an approved call ask a second time:
184
+ # every rewrite the append applies has to be applied to this side too.
185
+ def stored_form(args) = @ctx[:sessions].stored_form(args)
186
+
187
+ def park(call)
188
+ q = Thread::Queue.new
189
+ token = nil
190
+ # waiter first, then the durable request: a verdict can never land in
191
+ # the gap between the append's fan-out and the waiter existing
192
+ @waiting_mutex.synchronize do
193
+ token = (@park_seq += 1)
194
+ @waiting[token] = { session_id: call.session_id, call_id: call.id, queue: q }
195
+ end
196
+ # ...and the gate's own lookup happened before that waiter existed, so
197
+ # a verdict landing in between signalled nothing. Look again now that
198
+ # a signal has somewhere to land, or the pop below waits forever.
199
+ if (raced = recorded_verdict(call))
200
+ return raced
201
+ end
202
+
203
+ agent = @ctx[:loop].agent_for_session(call.session_id)
204
+ unless pending?(call.session_id, call.id) # a resume re-parks on the standing request
205
+ @ctx[:sessions].append(call.session_id, "approval/requested",
206
+ { call_id: call.id, name: call.name, args: call.args })
207
+ end
208
+ agent&.status = :waiting_approval
209
+ # cooperative under the fiber scheduler (parks the fiber); blocks the
210
+ # thread under plain minitest, where tests resolve from another thread
211
+ q.pop
212
+ ensure
213
+ @waiting_mutex.synchronize { @waiting.delete(token) } if token
214
+ restore(agent, call.session_id)
215
+ end
216
+
217
+ # Wake exactly one waiter parked on this (session, call_id). One durable
218
+ # verdict is one decision, so it releases one park; a second verdict for a
219
+ # reused id releases the next. FIFO by insertion, so the call that parked
220
+ # first is the first one a verdict frees.
221
+ def wake_one(session_id, call_id, payload)
222
+ entry = @waiting_mutex.synchronize do
223
+ token, e = @waiting.find do |_t, w|
224
+ w[:session_id] == session_id && w[:call_id] == call_id
225
+ end
226
+ @waiting.delete(token) if token
227
+ e
228
+ end
229
+ entry && entry[:queue].push(payload)
230
+ end
231
+
232
+ # Release every waiter still parked for a session onto one payload — the
233
+ # cancel path's escape hatch (deny_pending!), so a reused call id cannot
234
+ # leave a fiber holding the barrier open after the durable denials ran.
235
+ def drain_session_waiters(session_id, payload)
236
+ entries = @waiting_mutex.synchronize do
237
+ matched = @waiting.select { |_t, w| w[:session_id] == session_id }
238
+ matched.each_key { |t| @waiting.delete(t) }
239
+ matched.values
240
+ end
241
+ entries.each { |e| e[:queue].push(payload) }
242
+ end
243
+
244
+ # What the agent goes back to when a parked call comes out, derived from
245
+ # the LOG rather than from the label this park overwrote.
246
+ #
247
+ # A parallel run can park two calls at once (docs/subagents.md §5), and
248
+ # the fiber that unparks first must not announce a turn that is still
249
+ # waiting on a human: the socket reads this status to decide whether a
250
+ # cancel also has to deny_pending!, so a premature :running is a turn
251
+ # nobody can cancel and a sibling parked forever. While anything is
252
+ # still pending for the session, :waiting_approval stays true.
253
+ #
254
+ # And the restore that does happen is decided rather than left to
255
+ # whichever assignment runs last: a cancel requested while the call was
256
+ # parked has not stopped being true just because a verdict landed, so
257
+ # the fiber unparks into a turn that already knows it is stopping and
258
+ # the status says the same thing (docs/subagents.md §8).
259
+ def restore(agent, session_id)
260
+ return unless agent && agent.status == :waiting_approval
261
+ return unless pending(session_id).empty?
262
+
263
+ agent.status = agent.cancelled? ? :stopping : :running
264
+ end
265
+ end
266
+ end
267
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Terret
4
+ # ctx[:compactor] — turns a long history into a short one without breaking
5
+ # "model-visible means logged" (§2.5): the summary is itself a durable
6
+ # event, and derive_messages projects it in place of everything at or
7
+ # before its boundary. The §12 contract: upto_seq is ALWAYS the seq
8
+ # immediately preceding the compaction event — the projection prepends the
9
+ # summary, so any gap would interleave it among events that predate it.
10
+ # The boundary is computed at append time, after the summarizer returns:
11
+ # only projection-invisible events can land during summarization (the
12
+ # agent is still mid-turn, so nothing model-visible can interleave).
13
+ #
14
+ # Summary GENERATION is a seam: ctx[:summarizer] (sole provider, like the
15
+ # session store). A summarizer may decline by returning nil/empty —
16
+ # compaction is an optimization, so a decline warns and the next
17
+ # overweight turn retries. A summarizer that raises inside the trigger is
18
+ # isolated by emit dispatch; a manual compact! raises through.
19
+ class Compactor < Hames::Service
20
+ service_key :compactor
21
+ inject :sessions, :summarizer
22
+ config_schema budget: { type: Integer,
23
+ doc: "token budget that triggers compaction; unset or falsy disables it" }
24
+
25
+ def start(ctx)
26
+ @ctx = ctx
27
+ @budget = config[:budget]
28
+ # Always registered, gated at fire time: a hot-set budget (reconfigure)
29
+ # arms the trigger without a remount.
30
+ ctx.on("session/event") do |ev|
31
+ next unless @budget && ev.type == "turn/end"
32
+
33
+ compact!(ev.session_id) if overweight?(ev.session_id)
34
+ end
35
+ end
36
+
37
+ def reconfigure(config)
38
+ @budget = config[:budget]
39
+ end
40
+
41
+ # Everything derive_messages projects. Anything else may land under a
42
+ # boundary without being summarized; these may not.
43
+ MODEL_VISIBLE = %w[user/message context/injected assistant/message
44
+ tool/result session/compacted].freeze
45
+
46
+ # Summarize the whole projected history and append the boundary event.
47
+ # Returns the appended SessionEvent, or nil when the summarizer declined.
48
+ def compact!(session_id)
49
+ sessions = @ctx[:sessions]
50
+ history = sessions.derive_messages(session_id)
51
+ raise ArgumentError, "nothing to compact in #{session_id}" if history.empty?
52
+
53
+ base_seq = sessions.fetch(session_id).events.last.seq
54
+ summary = @ctx[:summarizer].summarize(history)
55
+ unless summary.is_a?(String) && !summary.strip.empty?
56
+ warn "terret: compaction skipped for #{session_id}: summarizer declined"
57
+ return nil
58
+ end
59
+
60
+ # Summarizing is a round trip; the boundary covers the whole prefix, so
61
+ # model-visible history arriving while it ran would be swept under a
62
+ # summary that never read it. Decline instead — the next overweight turn
63
+ # retries, and nothing is lost in the meantime.
64
+ raced = sessions.fetch(session_id).events
65
+ .count { |e| e.seq > base_seq && MODEL_VISIBLE.include?(e.type) }
66
+ unless raced.zero?
67
+ warn "terret: compaction skipped for #{session_id}: " \
68
+ "#{raced} model-visible event(s) landed while summarizing"
69
+ return nil
70
+ end
71
+
72
+ sessions.append(session_id, "session/compacted",
73
+ { upto_seq: sessions.fetch(session_id).events.last.seq,
74
+ summary: summary })
75
+ end
76
+
77
+ private
78
+
79
+ # The last step/end's prompt_tokens is what the next request will roughly
80
+ # cost before compaction; over budget means compact now, while idle.
81
+ def overweight?(session_id)
82
+ last = @ctx[:sessions].fetch(session_id).events.reverse_each
83
+ .find { |e| e.type == "step/end" }
84
+ tokens = last&.payload&.dig(:usage, :prompt_tokens)
85
+ !!(tokens && tokens >= @budget)
86
+ end
87
+ end
88
+
89
+ # ctx[:summarizer], the no-signup default: one model call through the
90
+ # :compactor role (a config row away on any adapter). Raises KeyError when
91
+ # the role is unconfigured — set the role or mount a provider that doesn't
92
+ # need one (terret-morph).
93
+ class RoleSummarizer < Hames::Service
94
+ service_key :summarizer
95
+ inject :llm
96
+ config_schema role: { type: [String, Symbol], default: :compactor,
97
+ doc: "llm role a summary is produced under" }
98
+
99
+ PROMPT = <<~TEXT
100
+ Summarize the conversation so far for your own future context. Preserve
101
+ user goals and constraints, decisions made, tool results that still
102
+ matter, and open questions. Reply with one compact briefing only.
103
+ TEXT
104
+
105
+ def start(ctx)
106
+ @ctx = ctx
107
+ end
108
+
109
+ def reconfigure(_config); end # :role is read per call — already live
110
+
111
+ def summarize(history)
112
+ request = LLM::Request.new(model: nil, system: PROMPT, messages: history, tools: [])
113
+ @ctx[:llm].stream(@ctx, role: config[:role] || :compactor, request: request) { |_ev| }.text
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "base64"
5
+ require "openssl"
6
+
7
+ module Terret
8
+ # ctx[:credentials] — plan §6.9. The one place a provider's secret is
9
+ # resolved, and its security point: every value it resolves is fed to the
10
+ # session scrubber (Sessions#register_scrubber), so a resolved credential can
11
+ # never reach the durable log even if a tool echoes it back into a result.
12
+ # That is what makes this more than a lookup table — it closes the loop the
13
+ # config-pattern redactor (docs/exec.md §6) leaves open, catching a secret by
14
+ # its exact bytes rather than by a shape a deployment had to name in advance.
15
+ #
16
+ # Resolution order is ENV-first by convention (`<PROVIDER>_API_KEY`), then an
17
+ # optional encrypted file store. ENV ALWAYS wins; the file is consulted only
18
+ # when ENV is silent (unset or empty), and a file present with no master key
19
+ # REFUSES rather than falling back to anything unprotected.
20
+ #
21
+ # On-disk format (a deployment writes it; a `trt credentials set` CLI is
22
+ # future work, plan §14 — only the format is promised here): JSON at the
23
+ # configured `file:` path, mapping each provider name to
24
+ #
25
+ # Base64.strict_encode64( iv(12 bytes) || auth_tag(16 bytes) || ciphertext )
26
+ #
27
+ # each entry AES-256-GCM under a 32-byte master key. The master key is ENV
28
+ # `TERRET_CREDENTIALS_KEY`, itself Base64 of exactly 32 bytes — generate one
29
+ # with `openssl rand -base64 32`. Both the file and the key are optional: with
30
+ # neither, ENV `<PROVIDER>_API_KEY` resolves on its own and the file store is
31
+ # inert, which is the shipped default.
32
+ #
33
+ # Deferred (plan §14): an OS-keychain backend, and the writer CLI above.
34
+ class Credentials < Hames::Service
35
+ service_key :credentials
36
+ inject :sessions
37
+ config_schema file: { type: String,
38
+ doc: "path to an AES-256-GCM encrypted credential store, provider => " \
39
+ "base64(iv+tag+ciphertext); the master key is ENV " \
40
+ "TERRET_CREDENTIALS_KEY (base64 of 32 bytes) and the per-provider " \
41
+ "ENV <PROVIDER>_API_KEY always wins — neither is config, and with " \
42
+ "no file only ENV resolves" }
43
+
44
+ class Error < StandardError; end
45
+
46
+ IV_LEN = 12 # AES-GCM standard nonce
47
+ TAG_LEN = 16 # AES-GCM authentication tag
48
+ KEY_LEN = 32 # AES-256
49
+
50
+ # Below this an exact-string scrub would match far too much: the empty
51
+ # string inserts the token between every character, and a two-character
52
+ # value paints ordinary prose. A real resolved credential dwarfs this
53
+ # floor, so a value under it is still resolved and returned — it just does
54
+ # not become an active scrub pattern (fail-safe: never corrupt the log to
55
+ # chase a value too short to be a secret worth catching by its bytes).
56
+ MIN_SCRUB_LENGTH = 8
57
+ REPLACEMENT = "[REDACTED]"
58
+
59
+ def start(ctx)
60
+ @secrets = []
61
+ @mutex = Mutex.new
62
+ # ONE scrubber over the growing set rather than a fresh registration per
63
+ # resolve: re-resolving a provider must not stack duplicate scrubbers, and
64
+ # a scrubber reaches every append whether it was registered early or late.
65
+ ctx[:sessions].register_scrubber(method(:scrub))
66
+ end
67
+
68
+ def reconfigure(_config); end # the file path is read per resolve
69
+
70
+ # The credential for a provider by name, or nil. ENV wins; then the file
71
+ # store when configured. A non-trivial resolved value is registered as an
72
+ # exact-string scrub pattern before it is returned, so anything that later
73
+ # echoes it into the log is caught.
74
+ def resolve(provider)
75
+ name = provider.to_s
76
+ env = ENV["#{env_name(name)}_API_KEY"]
77
+ value = (env unless env.nil? || env.empty?) || from_file(name)
78
+ remember(value) if value
79
+ value
80
+ end
81
+
82
+ private
83
+
84
+ # Exact-string, block-form gsub: never a Regexp a value's own metacharacters
85
+ # could turn into an over-match, and never gsub's STRING replacement (a `\0`
86
+ # inside a secret would paste the secret back in). Reads a snapshot under
87
+ # the lock so a concurrent resolve cannot mutate the set mid-fold.
88
+ def scrub(text)
89
+ @mutex.synchronize { @secrets.dup }.reduce(text) do |acc, secret|
90
+ acc.gsub(secret) { REPLACEMENT }
91
+ end
92
+ end
93
+
94
+ def remember(value)
95
+ return unless value.length >= MIN_SCRUB_LENGTH
96
+
97
+ @mutex.synchronize { @secrets << value unless @secrets.include?(value) }
98
+ end
99
+
100
+ # openrouter => OPENROUTER; a hyphen or dot in a provider name becomes an
101
+ # underscore so the env var is a legal shell identifier.
102
+ def env_name(name) = name.upcase.gsub(/[^A-Z0-9]/, "_")
103
+
104
+ # nil when no file is configured or it does not exist; RAISES when the file
105
+ # exists but no master key is set — never a plaintext or unprotected
106
+ # fallback. Read fresh each resolve, so a deployment can rewrite the store
107
+ # without a remount.
108
+ def from_file(name)
109
+ store = file_store or return nil
110
+ entry = store[name] or return nil
111
+
112
+ blob = begin
113
+ Base64.strict_decode64(entry.to_s)
114
+ rescue ArgumentError
115
+ raise Error, "a credential store entry is not valid Base64 " \
116
+ "(a truncated or corrupted store); refusing to resolve"
117
+ end
118
+ decrypt(blob)
119
+ end
120
+
121
+ def file_store
122
+ path = config[:file]
123
+ return nil unless path && File.exist?(path)
124
+
125
+ unless master_key
126
+ raise Error, "credential store #{path.inspect} exists but TERRET_CREDENTIALS_KEY is not " \
127
+ "set; refusing to resolve credentials without the master key"
128
+ end
129
+
130
+ parsed = JSON.parse(File.read(path))
131
+ raise Error, "credential store #{path.inspect} is not a JSON object" unless parsed.is_a?(Hash)
132
+
133
+ parsed
134
+ end
135
+
136
+ # nil only when the key is absent/empty; a present-but-malformed key RAISES
137
+ # rather than reading as absent, so a typo refuses instead of silently
138
+ # skipping to a fallback.
139
+ def master_key
140
+ raw = ENV["TERRET_CREDENTIALS_KEY"]
141
+ return nil if raw.nil? || raw.empty?
142
+
143
+ key = begin
144
+ Base64.strict_decode64(raw)
145
+ rescue ArgumentError
146
+ raise Error, "TERRET_CREDENTIALS_KEY is not valid Base64"
147
+ end
148
+ unless key.bytesize == KEY_LEN
149
+ raise Error, "TERRET_CREDENTIALS_KEY must be Base64 of exactly #{KEY_LEN} bytes (an AES-256 key)"
150
+ end
151
+
152
+ key
153
+ end
154
+
155
+ def decrypt(blob)
156
+ cipher = OpenSSL::Cipher.new("aes-256-gcm").decrypt
157
+ cipher.key = master_key
158
+ cipher.iv = blob.byteslice(0, IV_LEN)
159
+ cipher.auth_tag = blob.byteslice(IV_LEN, TAG_LEN)
160
+ plaintext = cipher.update(blob.byteslice(IV_LEN + TAG_LEN..) || "") + cipher.final
161
+ plaintext.force_encoding(Encoding::UTF_8)
162
+ rescue OpenSSL::Cipher::CipherError, ArgumentError, TypeError => e
163
+ # CipherError is the wrong-key/tampered case; ArgumentError and TypeError
164
+ # are what a TRUNCATED blob raises before decryption even begins — an iv or
165
+ # auth tag of the wrong length, or a nil slice past the end of the bytes.
166
+ # All three mean the entry cannot be decrypted, and all three fail closed
167
+ # through the friendly Error rather than a raw crypto backtrace.
168
+ raise Error, "a credential store entry failed to decrypt " \
169
+ "(wrong master key, or a truncated or tampered store): #{e.message}"
170
+ end
171
+ end
172
+ end