inquirex 0.7.0 → 0.9.4
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 +4 -4
- data/CHANGELOG.md +48 -18
- data/README.md +115 -48
- data/lib/inquirex/accumulator.rb +48 -6
- data/lib/inquirex/answers.rb +0 -10
- data/lib/inquirex/definition.rb +11 -53
- data/lib/inquirex/dsl/flow_builder.rb +52 -45
- data/lib/inquirex/dsl/send_email_builder.rb +88 -0
- data/lib/inquirex/engine.rb +94 -13
- data/lib/inquirex/errors.rb +20 -3
- data/lib/inquirex/node.rb +29 -0
- data/lib/inquirex/safe_source/call_spec.rb +42 -0
- data/lib/inquirex/safe_source/validator.rb +559 -0
- data/lib/inquirex/safe_source/vocabulary.rb +322 -0
- data/lib/inquirex/safe_source.rb +111 -0
- data/lib/inquirex/send_email.rb +206 -0
- data/lib/inquirex/template.rb +93 -0
- data/lib/inquirex/transcript.rb +93 -0
- data/lib/inquirex/version.rb +1 -1
- data/lib/inquirex.rb +48 -19
- metadata +12 -13
- data/lib/inquirex/actions/action.rb +0 -68
- data/lib/inquirex/actions/base.rb +0 -41
- data/lib/inquirex/actions/custom.rb +0 -31
- data/lib/inquirex/actions/outbox.rb +0 -57
- data/lib/inquirex/actions/runner.rb +0 -52
- data/lib/inquirex/actions/send_email.rb +0 -174
- data/lib/inquirex/actions/template.rb +0 -95
- data/lib/inquirex/actions/webhook.rb +0 -139
- data/lib/inquirex/actions.rb +0 -57
- data/lib/inquirex/dsl/action_builder.rb +0 -53
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inquirex
|
|
4
|
+
module SafeSource
|
|
5
|
+
# The registry of DSL words {Validator} accepts, and of the words it
|
|
6
|
+
# deliberately refuses.
|
|
7
|
+
#
|
|
8
|
+
# Every entry is *bound to the builder that implements it*: the six flow
|
|
9
|
+
# verbs are derived from {Node::VERBS}, step types from {Node::TYPES}, the
|
|
10
|
+
# rule vocabulary from {DSL::RuleHelpers}, and each scope declares the
|
|
11
|
+
# method list it governs (`vocabulary:`) so that {.undeclared_names} can
|
|
12
|
+
# name any builder method nobody has made an allowlist decision about.
|
|
13
|
+
# `spec/inquirex/safe_source/vocabulary_spec.rb` fails the build when that
|
|
14
|
+
# list is non-empty — which is what stops this table drifting away from the
|
|
15
|
+
# DSL it is supposed to describe.
|
|
16
|
+
#
|
|
17
|
+
# Downstream gems extend the vocabulary at boot rather than forking it.
|
|
18
|
+
#
|
|
19
|
+
# @example Teaching the allowlist about inquirex-llm's verbs
|
|
20
|
+
# V = Inquirex::SafeSource::Vocabulary
|
|
21
|
+
# V.register_scope(:llm_step, label: "an LLM step",
|
|
22
|
+
# vocabulary: -> { Inquirex::LLM::DSL::StepBuilder.public_instance_methods(false) })
|
|
23
|
+
# V.allow(:flow, :clarify, positional: %i[symbol], block: :llm_step)
|
|
24
|
+
# V.allow(:llm_step, :prompt, positional: %i[literal])
|
|
25
|
+
# V.exclude(:llm_step, :fallback, "a Ruby block cannot be validated")
|
|
26
|
+
module Vocabulary
|
|
27
|
+
# The only constant a flow definition may name.
|
|
28
|
+
ENTRY_CONSTANT = :Inquirex
|
|
29
|
+
|
|
30
|
+
# The only method that constant may receive.
|
|
31
|
+
ENTRY_METHOD = :define
|
|
32
|
+
|
|
33
|
+
# Keyword-descriptor key standing for "every keyword not named
|
|
34
|
+
# explicitly". Legitimate only where the real method takes `**rest`.
|
|
35
|
+
ANY_OTHER = :*
|
|
36
|
+
|
|
37
|
+
# Value kinds a `transition` accepts. Shared by every step-like scope.
|
|
38
|
+
TRANSITION_KEYWORDS = { to: :symbol, if_rule: :rule, requires_server: :literal }.freeze
|
|
39
|
+
|
|
40
|
+
# The `send_email` fields, and the shape each accepts. One definition
|
|
41
|
+
# serves both DSL forms — the flow-level keywords (`send_email to: ...`)
|
|
42
|
+
# and the block setters (`send_email do to "..." end`) — so the two can
|
|
43
|
+
# never drift apart. `headers` is `:literal` because it is a Hash; every
|
|
44
|
+
# other field is one static string.
|
|
45
|
+
EMAIL_FIELD_KEYWORDS = {
|
|
46
|
+
to: :string,
|
|
47
|
+
from: :string,
|
|
48
|
+
cc: :string,
|
|
49
|
+
bcc: :string,
|
|
50
|
+
reply_to: :string,
|
|
51
|
+
subject: :string,
|
|
52
|
+
text: :string,
|
|
53
|
+
markdown_text: :string,
|
|
54
|
+
html: :string,
|
|
55
|
+
headers: :literal
|
|
56
|
+
}.freeze
|
|
57
|
+
|
|
58
|
+
# Builder methods that exist for the builder's own sake and are not DSL
|
|
59
|
+
# words, so they need no allowlist decision. `params` is
|
|
60
|
+
# SendEmailBuilder's accumulator, read by FlowBuilder once the block has
|
|
61
|
+
# run — nothing a flow author writes.
|
|
62
|
+
PLUMBING = %i[build params method_missing respond_to_missing?].freeze
|
|
63
|
+
|
|
64
|
+
class << self
|
|
65
|
+
# Declares a nested scope (idempotent): a block whose statements are
|
|
66
|
+
# validated against their own table of calls.
|
|
67
|
+
#
|
|
68
|
+
# @param name [Symbol] scope name, e.g. :step
|
|
69
|
+
# @param label [String] how to name the scope in a violation message,
|
|
70
|
+
# article included ("a step")
|
|
71
|
+
# @param vocabulary [Proc, nil] returns the DSL words the scope's
|
|
72
|
+
# builder really implements; used by {.undeclared_names} to detect
|
|
73
|
+
# drift. Lazy, so load order does not matter.
|
|
74
|
+
# @return [void]
|
|
75
|
+
def register_scope(name, label:, vocabulary: nil)
|
|
76
|
+
sym = name.to_sym
|
|
77
|
+
@labels[sym] = label
|
|
78
|
+
@vocabularies[sym] = vocabulary
|
|
79
|
+
@calls[sym] ||= {}
|
|
80
|
+
@exclusions[sym] ||= {}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Allowlists one call inside one scope.
|
|
84
|
+
#
|
|
85
|
+
# @param scope [Symbol] scope name, e.g. :step
|
|
86
|
+
# @param name [Symbol] DSL word, e.g. :question
|
|
87
|
+
# @param positional [Array<Symbol>, Hash] see {CallSpec#positional}
|
|
88
|
+
# @param keywords [nil, Hash] see {CallSpec#keywords}
|
|
89
|
+
# @param block [Symbol] :forbidden, or the scope its block opens
|
|
90
|
+
# @return [CallSpec] the registered spec
|
|
91
|
+
# @raise [ArgumentError] when the scope was never registered
|
|
92
|
+
def allow(scope, name, positional: [], keywords: nil, block: :forbidden)
|
|
93
|
+
table = @calls.fetch(scope.to_sym) { raise ArgumentError, "Unknown SafeSource scope: #{scope.inspect}" }
|
|
94
|
+
table[name.to_sym] = CallSpec.new(positional:, keywords:, block:)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Records a DSL word that is knowingly *not* allowlisted, with the
|
|
98
|
+
# reason — which {Validator} quotes back to the author, so a rejection
|
|
99
|
+
# reads as a decision rather than an oversight.
|
|
100
|
+
#
|
|
101
|
+
# @param scope [Symbol] scope name
|
|
102
|
+
# @param name [Symbol] DSL word, e.g. :compute
|
|
103
|
+
# @param reason [String] why safe mode cannot accept it
|
|
104
|
+
# @return [String] the reason
|
|
105
|
+
# @raise [ArgumentError] when the scope was never registered
|
|
106
|
+
def exclude(scope, name, reason)
|
|
107
|
+
table = @exclusions.fetch(scope.to_sym) { raise ArgumentError, "Unknown SafeSource scope: #{scope.inspect}" }
|
|
108
|
+
table[name.to_sym] = reason
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# @param scope [Symbol]
|
|
112
|
+
# @param name [Symbol]
|
|
113
|
+
# @return [CallSpec, nil] nil when the call is not allowlisted
|
|
114
|
+
def spec_for(scope, name)
|
|
115
|
+
@calls.fetch(scope.to_sym, {})[name.to_sym]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# @param scope [Symbol]
|
|
119
|
+
# @param name [Symbol]
|
|
120
|
+
# @return [String, nil] why the call is excluded, nil when it was never
|
|
121
|
+
# part of the scope's vocabulary at all
|
|
122
|
+
def exclusion_for(scope, name)
|
|
123
|
+
@exclusions.fetch(scope.to_sym, {})[name.to_sym]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# @param scope [Symbol]
|
|
127
|
+
# @return [String] the scope's human-readable label, article included
|
|
128
|
+
def label_for(scope)
|
|
129
|
+
@labels.fetch(scope.to_sym, "an unknown")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# @param name [Symbol]
|
|
133
|
+
# @return [Boolean] whether the scope has been registered
|
|
134
|
+
def scope?(name)
|
|
135
|
+
@labels.key?(name.to_sym)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# @return [Array<Symbol>] every registered scope
|
|
139
|
+
def scopes
|
|
140
|
+
@labels.keys
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# @param scope [Symbol]
|
|
144
|
+
# @return [Array<Symbol>] every allowlisted call in the scope
|
|
145
|
+
def allowed_names(scope)
|
|
146
|
+
@calls.fetch(scope.to_sym, {}).keys
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# @param scope [Symbol]
|
|
150
|
+
# @return [Array<Symbol>] every knowingly excluded call in the scope
|
|
151
|
+
def excluded_names(scope)
|
|
152
|
+
@exclusions.fetch(scope.to_sym, {}).keys
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# The DSL words the scope's builder implements, per the `vocabulary:`
|
|
156
|
+
# binding given to {.register_scope}, minus builder plumbing.
|
|
157
|
+
#
|
|
158
|
+
# @param scope [Symbol]
|
|
159
|
+
# @return [Array<Symbol>] empty when the scope declared no binding
|
|
160
|
+
def governed_names(scope)
|
|
161
|
+
@vocabularies.fetch(scope.to_sym, nil)&.call.to_a.map(&:to_sym) - PLUMBING
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Builder methods with no allowlist decision: neither allowed nor
|
|
165
|
+
# knowingly excluded. Anything here is a new DSL word that silently
|
|
166
|
+
# became unusable in safe mode (or, worse, an old one that quietly
|
|
167
|
+
# gained a new meaning).
|
|
168
|
+
#
|
|
169
|
+
# @example Fail fast at boot
|
|
170
|
+
# raise "unreviewed DSL words" if V.scopes.any? { |s| V.undeclared_names(s).any? }
|
|
171
|
+
#
|
|
172
|
+
# @param scope [Symbol]
|
|
173
|
+
# @return [Array<Symbol>]
|
|
174
|
+
def undeclared_names(scope)
|
|
175
|
+
governed_names(scope) - allowed_names(scope) - excluded_names(scope)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Allowlist entries that no longer correspond to a real builder method
|
|
179
|
+
# — a typo, or a DSL word that has since been removed or renamed.
|
|
180
|
+
#
|
|
181
|
+
# @param scope [Symbol]
|
|
182
|
+
# @return [Array<Symbol>]
|
|
183
|
+
def stale_names(scope)
|
|
184
|
+
return [] if @vocabularies.fetch(scope.to_sym, nil).nil?
|
|
185
|
+
|
|
186
|
+
(allowed_names(scope) + excluded_names(scope)) - governed_names(scope)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# The spec for `Inquirex.define` itself.
|
|
190
|
+
#
|
|
191
|
+
# @return [CallSpec]
|
|
192
|
+
def entry_spec
|
|
193
|
+
CallSpec.new(positional: [], keywords: { id: :string, version: :string }, block: :flow)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Discards every registration and reinstalls the core vocabulary.
|
|
197
|
+
# Hosts call this to undo an extension; the specs call it to isolate.
|
|
198
|
+
#
|
|
199
|
+
# @return [void]
|
|
200
|
+
def reset!
|
|
201
|
+
@labels = {}
|
|
202
|
+
@calls = {}
|
|
203
|
+
@exclusions = {}
|
|
204
|
+
@vocabularies = {}
|
|
205
|
+
install_core!
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
private
|
|
209
|
+
|
|
210
|
+
# @return [void]
|
|
211
|
+
def install_core!
|
|
212
|
+
register_scope(:rule,
|
|
213
|
+
label: "a rule",
|
|
214
|
+
vocabulary: -> { DSL::RuleHelpers.public_instance_methods(false) })
|
|
215
|
+
register_scope(:flow,
|
|
216
|
+
label: "a flow",
|
|
217
|
+
vocabulary: -> { DSL::FlowBuilder.public_instance_methods(false) })
|
|
218
|
+
register_scope(:step,
|
|
219
|
+
label: "a step",
|
|
220
|
+
vocabulary: -> { DSL::StepBuilder.public_instance_methods(false) })
|
|
221
|
+
register_scope(:email,
|
|
222
|
+
label: "a send_email",
|
|
223
|
+
vocabulary: -> { DSL::SendEmailBuilder.public_instance_methods(false) })
|
|
224
|
+
|
|
225
|
+
install_rules!
|
|
226
|
+
install_flow!
|
|
227
|
+
install_step!
|
|
228
|
+
install_email!
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Rules are serializable AST objects, so their arguments are always a
|
|
232
|
+
# field name plus a literal — never an expression, which is what makes
|
|
233
|
+
# `equals(:a, ENV["X"])` a violation.
|
|
234
|
+
#
|
|
235
|
+
# @return [void]
|
|
236
|
+
def install_rules!
|
|
237
|
+
%i[equals contains greater_than less_than].each do |name|
|
|
238
|
+
allow :rule, name, positional: %i[symbol literal]
|
|
239
|
+
end
|
|
240
|
+
allow :rule, :not_empty, positional: %i[symbol]
|
|
241
|
+
%i[all any].each { |name| allow :rule, name, positional: { repeat: :rule, min: 1 } }
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# @return [void]
|
|
245
|
+
def install_flow!
|
|
246
|
+
allow :flow, :start, positional: %i[symbol]
|
|
247
|
+
allow :flow,
|
|
248
|
+
:meta,
|
|
249
|
+
keywords: { title: :literal, subtitle: :literal, brand: :literal, theme: :literal }
|
|
250
|
+
allow :flow,
|
|
251
|
+
:accumulator,
|
|
252
|
+
positional: %i[symbol],
|
|
253
|
+
keywords: { type: :type_name, default: :literal }
|
|
254
|
+
Node::VERBS.each { |verb| allow :flow, verb, positional: %i[symbol], block: :step }
|
|
255
|
+
# `send_email` takes an optional `if:` rule gate plus, in its inline
|
|
256
|
+
# form, the same field keywords the block setters cover. Both shapes
|
|
257
|
+
# are real DSL (`send_email to: "...", subject: "..."` and
|
|
258
|
+
# `send_email do to "..." end`), so both are described here; block
|
|
259
|
+
# values win at build time either way.
|
|
260
|
+
allow :flow,
|
|
261
|
+
:send_email,
|
|
262
|
+
keywords: EMAIL_FIELD_KEYWORDS.merge(if: :rule),
|
|
263
|
+
block: { optional: :email }
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# @return [void]
|
|
267
|
+
def install_step!
|
|
268
|
+
allow :step, :type, positional: %i[type_name]
|
|
269
|
+
allow :step, :question, positional: %i[string]
|
|
270
|
+
allow :step, :text, positional: %i[string]
|
|
271
|
+
allow :step, :options, positional: %i[literal]
|
|
272
|
+
allow :step, :default, positional: %i[literal]
|
|
273
|
+
# `required` is inert metadata: Node coerces it with `value ? true :
|
|
274
|
+
# false` and serializes it as `"required": false`. It reaches nothing
|
|
275
|
+
# outside the definition, and an author who can write the DSL text can
|
|
276
|
+
# already delete the question outright — strictly more powerful than
|
|
277
|
+
# marking it skippable — so it is allowed rather than excluded.
|
|
278
|
+
# `{ optional: :literal }` mirrors `required(value = true)`: both bare
|
|
279
|
+
# `required` and `required false` are real DSL.
|
|
280
|
+
allow :step, :required, positional: { optional: :literal }
|
|
281
|
+
allow :step, :skip_if, positional: %i[rule]
|
|
282
|
+
allow :step, :transition, keywords: TRANSITION_KEYWORDS
|
|
283
|
+
allow :step,
|
|
284
|
+
:accumulate,
|
|
285
|
+
positional: %i[symbol],
|
|
286
|
+
keywords: { lookup: :literal, per_selection: :literal, per_unit: :literal, flat: :literal }
|
|
287
|
+
# widget(type:, target:, **opts) and price(**kwargs) really do take
|
|
288
|
+
# open keyword lists — a widget's options and a price lookup's option
|
|
289
|
+
# keys are arbitrary — so ANY_OTHER is faithful rather than lax.
|
|
290
|
+
allow :step, :widget, keywords: { type: :symbol, target: :symbol, ANY_OTHER => :literal }
|
|
291
|
+
allow :step,
|
|
292
|
+
:price,
|
|
293
|
+
keywords: { lookup: :literal, per_selection: :literal, per_unit: :literal,
|
|
294
|
+
flat: :literal, ANY_OTHER => :literal }
|
|
295
|
+
exclude :step, :compute, "a compute block is arbitrary Ruby, indistinguishable from a payload"
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# The `send_email` setters take one static string each (`headers` a
|
|
299
|
+
# literal Hash). {SendEmail} never delivers anything and the gem never
|
|
300
|
+
# renders a body, so what a stored definition can express here is a
|
|
301
|
+
# template the *host* chooses to act on — which is why the verb needs
|
|
302
|
+
# no exclusions. `{{field}}` placeholders are ordinary text to the
|
|
303
|
+
# parser; the shape enforced is "a string literal with no Ruby in it",
|
|
304
|
+
# so `#{}`, a constant and a method call are rejected exactly as
|
|
305
|
+
# everywhere else.
|
|
306
|
+
#
|
|
307
|
+
# The `{ file: "path" }` body form is not expressible: bodies are
|
|
308
|
+
# `:string`, so a stored flow cannot make the gem `File.read` an
|
|
309
|
+
# attacker-chosen path at definition time.
|
|
310
|
+
#
|
|
311
|
+
# @return [void]
|
|
312
|
+
def install_email!
|
|
313
|
+
EMAIL_FIELD_KEYWORDS.each do |setter, shape|
|
|
314
|
+
allow :email, setter, positional: [shape]
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
reset!
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "safe_source/call_spec"
|
|
6
|
+
require_relative "safe_source/vocabulary"
|
|
7
|
+
require_relative "safe_source/validator"
|
|
8
|
+
|
|
9
|
+
module Inquirex
|
|
10
|
+
# Decides whether a string of flow DSL is safe to evaluate — *without*
|
|
11
|
+
# evaluating it.
|
|
12
|
+
#
|
|
13
|
+
# {Inquirex.load_dsl} is an `eval`. Whenever the text comes from somewhere
|
|
14
|
+
# other than your own repository — a database column a customer edits, an
|
|
15
|
+
# upload, an LLM, a visual builder's "sync" button — evaluating it unguarded
|
|
16
|
+
# is arbitrary code execution in the process that loads the flow. As of 0.9.2
|
|
17
|
+
# `load_dsl` therefore runs {SafeSource.validate!} first, and only source that
|
|
18
|
+
# matches the real DSL vocabulary ({Vocabulary}) with literal arguments gets
|
|
19
|
+
# past it.
|
|
20
|
+
#
|
|
21
|
+
# Use {.validate} or {.safe?} directly to audit stored definitions without
|
|
22
|
+
# loading them — the answer is "would `load_dsl` accept this?", which is
|
|
23
|
+
# exactly what you want before a deploy tightens the allowlist.
|
|
24
|
+
#
|
|
25
|
+
# @example Reject before evaluating
|
|
26
|
+
# Inquirex.load_dsl(customer.flow_dsl) # validates, then evals
|
|
27
|
+
# Inquirex.load_dsl(File.read("flow.rb"), unsafe: true) # your own file: skip validation
|
|
28
|
+
#
|
|
29
|
+
# @example Audit a table of stored definitions
|
|
30
|
+
# Qualifier.find_each do |q|
|
|
31
|
+
# violations = Inquirex::SafeSource.validate(q.flow_dsl)
|
|
32
|
+
# puts "REJECT #{q.id}: #{violations.join("; ")}" if violations.any?
|
|
33
|
+
# end
|
|
34
|
+
#
|
|
35
|
+
# @example Raise the ceilings for an unusually large questionnaire
|
|
36
|
+
# Inquirex::SafeSource.max_source_bytes = 256 * 1024
|
|
37
|
+
# Inquirex::SafeSource.max_depth = 32
|
|
38
|
+
module SafeSource
|
|
39
|
+
# Default ceiling on DSL source size, in bytes.
|
|
40
|
+
#
|
|
41
|
+
# Real flows are tiny: the largest definition anywhere in this project or
|
|
42
|
+
# its downstream app — a 47-step loan application with three levels of
|
|
43
|
+
# composed rules — is under 6 KB. 64 KiB is an order of magnitude above
|
|
44
|
+
# that, comfortably fits a several-hundred-step questionnaire, and still
|
|
45
|
+
# bounds the work handed to Prism (and to any formatter the host runs on
|
|
46
|
+
# the same text), neither of which should be fed megabytes of hostile input.
|
|
47
|
+
DEFAULT_MAX_SOURCE_BYTES = 64 * 1024
|
|
48
|
+
|
|
49
|
+
# Default ceiling on AST nesting depth.
|
|
50
|
+
#
|
|
51
|
+
# Measured against every flow in this gem's specs and examples and the
|
|
52
|
+
# downstream app's fixtures, the deepest legitimate construct needs 9 levels
|
|
53
|
+
# (`define` → block → `ask` → block → `transition` → `all` → `any` →
|
|
54
|
+
# `equals` → literal). 24 leaves better than 2.5x headroom while keeping
|
|
55
|
+
# the validator's own recursion clear of a stack overflow triggered by
|
|
56
|
+
# `all(all(all(...)))` nested a million deep.
|
|
57
|
+
DEFAULT_MAX_DEPTH = 24
|
|
58
|
+
|
|
59
|
+
@max_source_bytes = DEFAULT_MAX_SOURCE_BYTES
|
|
60
|
+
@max_depth = DEFAULT_MAX_DEPTH
|
|
61
|
+
|
|
62
|
+
class << self
|
|
63
|
+
# Ceiling on DSL source size in bytes, applied by {.validate} and
|
|
64
|
+
# therefore by {Inquirex.load_dsl}.
|
|
65
|
+
#
|
|
66
|
+
# @return [Integer]
|
|
67
|
+
attr_accessor :max_source_bytes
|
|
68
|
+
|
|
69
|
+
# Ceiling on AST nesting depth, applied by {.validate} and therefore by
|
|
70
|
+
# {Inquirex.load_dsl}.
|
|
71
|
+
#
|
|
72
|
+
# @return [Integer]
|
|
73
|
+
attr_accessor :max_depth
|
|
74
|
+
|
|
75
|
+
# Every reason the source would be rejected. An empty array means the
|
|
76
|
+
# source is inside the allowlist — it says nothing about whether the flow
|
|
77
|
+
# is semantically valid (unknown step reference, missing `start`), which
|
|
78
|
+
# only evaluation can decide.
|
|
79
|
+
#
|
|
80
|
+
# @param source [String, nil] Inquirex DSL source
|
|
81
|
+
# @param max_bytes [Integer] override the size ceiling for this call
|
|
82
|
+
# @param max_depth [Integer] override the depth ceiling for this call
|
|
83
|
+
# @return [Array<String>] `"line N: reason"` messages, empty when safe
|
|
84
|
+
def validate(source, max_bytes: max_source_bytes, max_depth: self.max_depth)
|
|
85
|
+
Validator.new(source, max_bytes:, max_depth:).violations
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @param source [String, nil] Inquirex DSL source
|
|
89
|
+
# @param max_bytes [Integer] override the size ceiling for this call
|
|
90
|
+
# @param max_depth [Integer] override the depth ceiling for this call
|
|
91
|
+
# @return [Boolean] true when the source is inside the allowlist
|
|
92
|
+
def safe?(source, max_bytes: max_source_bytes, max_depth: self.max_depth)
|
|
93
|
+
validate(source, max_bytes:, max_depth:).empty?
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Validates, and raises unless the source is inside the allowlist.
|
|
97
|
+
#
|
|
98
|
+
# @param source [String, nil] Inquirex DSL source
|
|
99
|
+
# @param max_bytes [Integer] override the size ceiling for this call
|
|
100
|
+
# @param max_depth [Integer] override the depth ceiling for this call
|
|
101
|
+
# @return [String] the source, unchanged, when it is safe
|
|
102
|
+
# @raise [Errors::UnsafeSourceError] listing every violation found
|
|
103
|
+
def validate!(source, max_bytes: max_source_bytes, max_depth: self.max_depth)
|
|
104
|
+
violations = validate(source, max_bytes:, max_depth:)
|
|
105
|
+
raise Errors::UnsafeSourceError, violations unless violations.empty?
|
|
106
|
+
|
|
107
|
+
source
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inquirex
|
|
4
|
+
# A declarative email built from the collected answers, declared at flow
|
|
5
|
+
# level with the DSL verb `send_email`. This is the only server-side
|
|
6
|
+
# completion declaration the core gem carries — anything richer (webhooks,
|
|
7
|
+
# CRM pushes, custom code) belongs to the host application.
|
|
8
|
+
#
|
|
9
|
+
# send_email if: not_empty(:email) do
|
|
10
|
+
# to "{{email}}"
|
|
11
|
+
# from "forms@agentica.group"
|
|
12
|
+
# subject "Thanks {{name}} — we got your inquiry"
|
|
13
|
+
# markdown_text <<~TEXT
|
|
14
|
+
# Hi {{name}},
|
|
15
|
+
#
|
|
16
|
+
# We received your answers and will reply within one business day.
|
|
17
|
+
#
|
|
18
|
+
# {{answers_summary}}
|
|
19
|
+
# TEXT
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# Nothing is ever delivered by this gem. A SendEmail is data: templated
|
|
23
|
+
# header fields plus one or more body templates, serialized into the
|
|
24
|
+
# definition JSON under "send_emails". The host application decides when
|
|
25
|
+
# (and whether) to render and deliver — either from the serialized fields
|
|
26
|
+
# directly, or via #to_mail, which builds a Mail::Message (the object
|
|
27
|
+
# ActionMailer wraps).
|
|
28
|
+
#
|
|
29
|
+
# Scalar fields (to, from, cc, bcc, reply_to, subject) and the text /
|
|
30
|
+
# markdown_text bodies render {{field}} values verbatim; the html body
|
|
31
|
+
# HTML-escapes every interpolated value automatically. markdown_text is
|
|
32
|
+
# carried on the wire as markdown — the core gem never renders Markdown
|
|
33
|
+
# to HTML (no dependencies); hosts that want an HTML part render it
|
|
34
|
+
# themselves.
|
|
35
|
+
#
|
|
36
|
+
# Bodies accept an inline template String or { file: "path" }, which is
|
|
37
|
+
# read once at definition time and inlined — a definition rehydrated from
|
|
38
|
+
# JSON never touches the filesystem.
|
|
39
|
+
#
|
|
40
|
+
# The mail gem is a soft dependency, required only when #to_mail is
|
|
41
|
+
# called. Rails hosts always have it (ActionMailer depends on it).
|
|
42
|
+
class SendEmail
|
|
43
|
+
# Scalar header fields rendered verbatim via Template.render_text in #to_mail and #to_h.
|
|
44
|
+
SCALAR_FIELDS = %i[to from cc bcc reply_to subject].freeze
|
|
45
|
+
|
|
46
|
+
# @return [String] required recipient / subject templates ({{field}} placeholders allowed)
|
|
47
|
+
attr_reader :to, :subject
|
|
48
|
+
|
|
49
|
+
# @return [String, nil] optional address templates ({{field}} placeholders allowed)
|
|
50
|
+
attr_reader :from, :cc, :bcc, :reply_to
|
|
51
|
+
|
|
52
|
+
# @return [String, nil] body template, inlined at definition time when { file: } was given
|
|
53
|
+
attr_reader :text, :markdown_text, :html
|
|
54
|
+
|
|
55
|
+
# @return [Hash{String => String}] extra headers (values support {{field}})
|
|
56
|
+
attr_reader :headers
|
|
57
|
+
|
|
58
|
+
# @return [Rules::Base, nil] gate — the email applies only when the rule is true
|
|
59
|
+
attr_reader :rule
|
|
60
|
+
|
|
61
|
+
# @param to [String] recipient template (required; keyword defaults to nil
|
|
62
|
+
# so a missing field raises the friendly DefinitionError from #validate!)
|
|
63
|
+
# @param subject [String] subject template (required)
|
|
64
|
+
# @param text [String, Hash, nil] plain-text body template or { file: }
|
|
65
|
+
# @param markdown_text [String, Hash, nil] Markdown body template or { file: }
|
|
66
|
+
# @param html [String, Hash, nil] HTML body template or { file: }
|
|
67
|
+
# @param headers [Hash] extra headers (values support {{field}})
|
|
68
|
+
# @param rule [Rules::Base, nil] serializable gate (the DSL's if: option)
|
|
69
|
+
# @raise [Errors::DefinitionError] when required fields are missing
|
|
70
|
+
def initialize(to: nil, subject: nil, from: nil, cc: nil, bcc: nil, reply_to: nil,
|
|
71
|
+
text: nil, markdown_text: nil, html: nil, headers: {}, rule: nil)
|
|
72
|
+
@to = to
|
|
73
|
+
@from = from
|
|
74
|
+
@cc = cc
|
|
75
|
+
@bcc = bcc
|
|
76
|
+
@reply_to = reply_to
|
|
77
|
+
@subject = subject
|
|
78
|
+
@text = resolve_body(text)
|
|
79
|
+
@markdown_text = resolve_body(markdown_text)
|
|
80
|
+
@html = resolve_body(html)
|
|
81
|
+
@headers = headers.transform_keys(&:to_s).freeze
|
|
82
|
+
@rule = rule
|
|
83
|
+
validate!
|
|
84
|
+
freeze
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Whether this email should be built for the given answers — true when
|
|
88
|
+
# no gate was declared or the gate rule evaluates to true.
|
|
89
|
+
#
|
|
90
|
+
# @param answers_hash [Hash] step_id => value context for rule evaluation
|
|
91
|
+
# @return [Boolean]
|
|
92
|
+
def applicable?(answers_hash)
|
|
93
|
+
@rule.nil? || @rule.evaluate(answers_hash)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Builds a Mail::Message from the templates and the given answers.
|
|
97
|
+
# Pure function — safe to call from a background job to rebuild
|
|
98
|
+
# messages from persisted answers. The text part is @text, falling back
|
|
99
|
+
# to @markdown_text rendered verbatim (Markdown reads fine as plain
|
|
100
|
+
# text); the html part is @html when present.
|
|
101
|
+
#
|
|
102
|
+
# @param answers [Answers]
|
|
103
|
+
# @return [Mail::Message]
|
|
104
|
+
def to_mail(answers)
|
|
105
|
+
require_mail!
|
|
106
|
+
mail = ::Mail.new
|
|
107
|
+
SCALAR_FIELDS.each do |field|
|
|
108
|
+
value = public_send(field)
|
|
109
|
+
mail.public_send(:"#{field}=", Template.render_text(value, answers)) if value
|
|
110
|
+
end
|
|
111
|
+
@headers.each { |name, value| mail.header[name] = Template.render_text(value.to_s, answers) }
|
|
112
|
+
attach_bodies(mail, answers)
|
|
113
|
+
mail
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# @return [Hash] wire format, same shape .from_h accepts
|
|
117
|
+
def to_h
|
|
118
|
+
hash = {}
|
|
119
|
+
hash["if"] = @rule.to_h if @rule
|
|
120
|
+
SCALAR_FIELDS.each do |field|
|
|
121
|
+
value = public_send(field)
|
|
122
|
+
hash[field.to_s] = value if value
|
|
123
|
+
end
|
|
124
|
+
hash["text"] = @text if @text
|
|
125
|
+
hash["markdown_text"] = @markdown_text if @markdown_text
|
|
126
|
+
hash["html"] = @html if @html
|
|
127
|
+
hash["headers"] = @headers unless @headers.empty?
|
|
128
|
+
hash
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# @param hash [Hash] string or symbol keys
|
|
132
|
+
# @return [SendEmail]
|
|
133
|
+
def self.from_h(hash)
|
|
134
|
+
fetch = ->(key) { hash[key.to_s] || hash[key.to_sym] }
|
|
135
|
+
rule_data = fetch.call(:if)
|
|
136
|
+
new(
|
|
137
|
+
to: fetch.call(:to),
|
|
138
|
+
from: fetch.call(:from),
|
|
139
|
+
cc: fetch.call(:cc),
|
|
140
|
+
bcc: fetch.call(:bcc),
|
|
141
|
+
reply_to: fetch.call(:reply_to),
|
|
142
|
+
subject: fetch.call(:subject),
|
|
143
|
+
text: fetch.call(:text),
|
|
144
|
+
markdown_text: fetch.call(:markdown_text),
|
|
145
|
+
html: fetch.call(:html),
|
|
146
|
+
headers: fetch.call(:headers) || {},
|
|
147
|
+
rule: rule_data ? Rules::Base.from_h(rule_data) : nil
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
def attach_bodies(mail, answers)
|
|
154
|
+
text_source = @text || @markdown_text
|
|
155
|
+
text = text_source && Template.render_text(text_source, answers)
|
|
156
|
+
html = @html && Template.render_html(@html, answers)
|
|
157
|
+
if text && html
|
|
158
|
+
mail.text_part = build_part("text/plain; charset=UTF-8", text)
|
|
159
|
+
mail.html_part = build_part("text/html; charset=UTF-8", html)
|
|
160
|
+
elsif html
|
|
161
|
+
mail.content_type = "text/html; charset=UTF-8"
|
|
162
|
+
mail.body = html
|
|
163
|
+
else
|
|
164
|
+
mail.body = text
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def build_part(content_type, body)
|
|
169
|
+
part = ::Mail::Part.new
|
|
170
|
+
part.content_type = content_type
|
|
171
|
+
part.body = body
|
|
172
|
+
part
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Inline template string, or { file: "path" } read once at definition time.
|
|
176
|
+
def resolve_body(value)
|
|
177
|
+
return value if value.nil? || value.is_a?(String)
|
|
178
|
+
|
|
179
|
+
path = value.is_a?(Hash) && (value[:file] || value["file"])
|
|
180
|
+
return File.read(File.expand_path(path)) if path.is_a?(String)
|
|
181
|
+
|
|
182
|
+
raise Errors::DefinitionError,
|
|
183
|
+
"send_email body must be a template String or { file: \"path\" }, got #{value.inspect}"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def validate!
|
|
187
|
+
raise Errors::DefinitionError, "send_email requires to:" if blank?(@to)
|
|
188
|
+
raise Errors::DefinitionError, "send_email requires subject:" if blank?(@subject)
|
|
189
|
+
return unless @text.nil? && @markdown_text.nil? && @html.nil?
|
|
190
|
+
|
|
191
|
+
raise Errors::DefinitionError, "send_email requires a text:, markdown_text: or html: body"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def blank?(value) = value.nil? || value.to_s.strip.empty?
|
|
195
|
+
|
|
196
|
+
def require_mail!
|
|
197
|
+
return if defined?(::Mail)
|
|
198
|
+
|
|
199
|
+
require "mail"
|
|
200
|
+
rescue LoadError
|
|
201
|
+
raise Errors::SendEmailError,
|
|
202
|
+
"send_email requires the mail gem — add `gem \"mail\"` to your Gemfile " \
|
|
203
|
+
"(Rails applications already have it via ActionMailer)"
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|