protege 0.1.0.alpha.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.
Files changed (255) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +24 -0
  3. data/Rakefile +12 -0
  4. data/app/assets/builds/protege.css +1128 -0
  5. data/app/assets/javascripts/protege/attachment_picker.js +163 -0
  6. data/app/assets/javascripts/protege/copy.js +80 -0
  7. data/app/assets/javascripts/protege/forms.js +28 -0
  8. data/app/assets/javascripts/protege/keys.js +212 -0
  9. data/app/assets/javascripts/protege/resize.js +149 -0
  10. data/app/assets/javascripts/protege/theme.js +25 -0
  11. data/app/assets/stylesheets/protege/application.css +15 -0
  12. data/app/assets/tailwind/application.css +137 -0
  13. data/app/controllers/concerns/protege/persona_scoped.rb +22 -0
  14. data/app/controllers/concerns/protege/thread_scoped.rb +22 -0
  15. data/app/controllers/protege/access_rules_controller.rb +72 -0
  16. data/app/controllers/protege/application_controller.rb +18 -0
  17. data/app/controllers/protege/archives_controller.rb +52 -0
  18. data/app/controllers/protege/email_domains_controller.rb +72 -0
  19. data/app/controllers/protege/home_controller.rb +15 -0
  20. data/app/controllers/protege/messages_controller.rb +46 -0
  21. data/app/controllers/protege/mta_domains_syncs_controller.rb +22 -0
  22. data/app/controllers/protege/personas_controller.rb +118 -0
  23. data/app/controllers/protege/replies_controller.rb +64 -0
  24. data/app/controllers/protege/responsibilities_controller.rb +82 -0
  25. data/app/controllers/protege/responsibility_runs_controller.rb +46 -0
  26. data/app/controllers/protege/threads_controller.rb +75 -0
  27. data/app/controllers/protege/traces/searches_controller.rb +19 -0
  28. data/app/controllers/protege/traces_controller.rb +52 -0
  29. data/app/helpers/protege/application_helper.rb +30 -0
  30. data/app/helpers/protege/components/badges_helper.rb +59 -0
  31. data/app/helpers/protege/components/buttons_helper.rb +141 -0
  32. data/app/helpers/protege/components/cards_helper.rb +48 -0
  33. data/app/helpers/protege/components/components_helper.rb +30 -0
  34. data/app/helpers/protege/components/dialogs_helper.rb +36 -0
  35. data/app/helpers/protege/components/forms_helper.rb +223 -0
  36. data/app/helpers/protege/components/hotkeys_helper.rb +60 -0
  37. data/app/helpers/protege/components/icons_helper.rb +41 -0
  38. data/app/helpers/protege/components/introspection_helper.rb +162 -0
  39. data/app/helpers/protege/components/layout_helper.rb +169 -0
  40. data/app/helpers/protege/components/menus_helper.rb +68 -0
  41. data/app/helpers/protege/components/pagination_helper.rb +100 -0
  42. data/app/helpers/protege/components/sidebar_helper.rb +211 -0
  43. data/app/helpers/protege/components/tables_helper.rb +59 -0
  44. data/app/helpers/protege/components/typography_helper.rb +64 -0
  45. data/app/helpers/protege/email_domains_helper.rb +22 -0
  46. data/app/helpers/protege/messages_helper.rb +152 -0
  47. data/app/helpers/protege/personas_helper.rb +90 -0
  48. data/app/helpers/protege/responsibilities_helper.rb +63 -0
  49. data/app/helpers/protege/threads_helper.rb +118 -0
  50. data/app/helpers/protege/trace_helper.rb +22 -0
  51. data/app/hooks/protege/event_logger_hook.rb +109 -0
  52. data/app/jobs/protege/application_job.rb +41 -0
  53. data/app/jobs/protege/console_inference_job.rb +57 -0
  54. data/app/jobs/protege/inference_job.rb +60 -0
  55. data/app/jobs/protege/mail_provisioning_job.rb +21 -0
  56. data/app/jobs/protege/responsibility_job.rb +70 -0
  57. data/app/jobs/protege/responsibility_tick_job.rb +19 -0
  58. data/app/mailboxes/protege/agent_mailbox.rb +123 -0
  59. data/app/mailers/protege/application_mailer.rb +65 -0
  60. data/app/models/concerns/protege/broadcastable_email_domain.rb +48 -0
  61. data/app/models/concerns/protege/broadcastable_message.rb +47 -0
  62. data/app/models/concerns/protege/broadcastable_persona.rb +50 -0
  63. data/app/models/concerns/protege/broadcastable_responsibility.rb +51 -0
  64. data/app/models/concerns/protege/broadcastable_thread.rb +55 -0
  65. data/app/models/concerns/protege/tool_scoped.rb +88 -0
  66. data/app/models/protege/access_rule.rb +50 -0
  67. data/app/models/protege/application_record.rb +18 -0
  68. data/app/models/protege/current.rb +23 -0
  69. data/app/models/protege/email_domain.rb +136 -0
  70. data/app/models/protege/email_thread.rb +81 -0
  71. data/app/models/protege/message.rb +401 -0
  72. data/app/models/protege/persona.rb +229 -0
  73. data/app/models/protege/responsibility.rb +98 -0
  74. data/app/models/protege/responsibility_run.rb +68 -0
  75. data/app/models/protege/tool_use.rb +115 -0
  76. data/app/models/protege/trace.rb +71 -0
  77. data/app/providers/protege/open_router_provider.rb +317 -0
  78. data/app/resolvers/README.md +255 -0
  79. data/app/resolvers/protege/load_file_resolver.rb +86 -0
  80. data/app/resolvers/protege/load_record_resolver.rb +80 -0
  81. data/app/resolvers/protege/load_text_resolver.rb +40 -0
  82. data/app/resolvers/protege/thread_history_resolver.rb +116 -0
  83. data/app/services/protege/introspection_tool_call_presentation.rb +134 -0
  84. data/app/services/protege/message_search.rb +114 -0
  85. data/app/services/protege/trace_search.rb +102 -0
  86. data/app/subscribers/protege/introspection_broadcaster.rb +324 -0
  87. data/app/tools/protege/create_file_tool.rb +109 -0
  88. data/app/tools/protege/read_attachment_tool.rb +101 -0
  89. data/app/tools/protege/search_emails_tool.rb +129 -0
  90. data/app/tools/protege/send_email_tool.rb +240 -0
  91. data/app/tools/protege/web_fetch_tool.rb +224 -0
  92. data/app/tools/protege/web_search_tool.rb +131 -0
  93. data/app/views/layouts/protege/application.html.slim +19 -0
  94. data/app/views/protege/access_rules/create.turbo_stream.slim +6 -0
  95. data/app/views/protege/email_domains/_email_domain_sidebar_item.html.slim +4 -0
  96. data/app/views/protege/email_domains/_form.html.slim +10 -0
  97. data/app/views/protege/email_domains/_sidebar.html.slim +11 -0
  98. data/app/views/protege/email_domains/index.html.slim +6 -0
  99. data/app/views/protege/email_domains/new.html.slim +7 -0
  100. data/app/views/protege/email_domains/show.html.slim +36 -0
  101. data/app/views/protege/home/show.html.slim +26 -0
  102. data/app/views/protege/messages/_message.html.slim +19 -0
  103. data/app/views/protege/messages/_sidebar.html.slim +13 -0
  104. data/app/views/protege/messages/index.html.slim +20 -0
  105. data/app/views/protege/personas/_access_rule.html.slim +7 -0
  106. data/app/views/protege/personas/_access_rule_form.html.slim +8 -0
  107. data/app/views/protege/personas/_access_rules.html.slim +19 -0
  108. data/app/views/protege/personas/_actions.html.slim +15 -0
  109. data/app/views/protege/personas/_form.html.slim +29 -0
  110. data/app/views/protege/personas/_persona_sidebar_item.html.slim +5 -0
  111. data/app/views/protege/personas/_sidebar.html.slim +11 -0
  112. data/app/views/protege/personas/edit.html.slim +7 -0
  113. data/app/views/protege/personas/index.html.slim +6 -0
  114. data/app/views/protege/personas/new.html.slim +7 -0
  115. data/app/views/protege/personas/show.html.slim +37 -0
  116. data/app/views/protege/responsibilities/_form.html.slim +21 -0
  117. data/app/views/protege/responsibilities/_responsibility_sidebar_item.html.slim +5 -0
  118. data/app/views/protege/responsibilities/_sidebar.html.slim +11 -0
  119. data/app/views/protege/responsibilities/edit.html.slim +7 -0
  120. data/app/views/protege/responsibilities/index.html.slim +6 -0
  121. data/app/views/protege/responsibilities/new.html.slim +7 -0
  122. data/app/views/protege/responsibilities/show.html.slim +22 -0
  123. data/app/views/protege/responsibility_runs/show.html.slim +13 -0
  124. data/app/views/protege/shared/_confirm_dialog.html.slim +22 -0
  125. data/app/views/protege/shared/_header.html.slim +14 -0
  126. data/app/views/protege/shared/_hotkeys.html.slim +43 -0
  127. data/app/views/protege/threads/_composer_footer.html.slim +15 -0
  128. data/app/views/protege/threads/_introspection.html.slim +7 -0
  129. data/app/views/protege/threads/_reasoning.html.slim +4 -0
  130. data/app/views/protege/threads/_reply_form.html.slim +7 -0
  131. data/app/views/protege/threads/_run_marker.html.slim +3 -0
  132. data/app/views/protege/threads/_sidebar.html.slim +15 -0
  133. data/app/views/protege/threads/_thread.html.slim +6 -0
  134. data/app/views/protege/threads/_tool_call.html.slim +8 -0
  135. data/app/views/protege/threads/index.html.slim +6 -0
  136. data/app/views/protege/threads/new.html.slim +15 -0
  137. data/app/views/protege/threads/show.html.slim +25 -0
  138. data/app/views/protege/traces/_sidebar.html.slim +15 -0
  139. data/app/views/protege/traces/index.html.slim +11 -0
  140. data/app/views/protege/traces/searches/_sidebar.html.slim +13 -0
  141. data/app/views/protege/traces/searches/show.html.slim +22 -0
  142. data/app/views/protege/traces/show.html.slim +28 -0
  143. data/config/routes.rb +33 -0
  144. data/db/migrate/20260520000001_create_protege_tables.rb +105 -0
  145. data/db/migrate/20260706120130_create_protege_traces.rb +30 -0
  146. data/db/migrate/20260707120000_replace_persona_active_with_archived_at.rb +24 -0
  147. data/db/migrate/20260708120000_add_disabled_tool_ids_to_personas.rb +22 -0
  148. data/lib/generators/protege/extension_naming.rb +38 -0
  149. data/lib/generators/protege/hook/hook_generator.rb +33 -0
  150. data/lib/generators/protege/hook/templates/hook.rb.tt +36 -0
  151. data/lib/generators/protege/install/install_generator.rb +170 -0
  152. data/lib/generators/protege/install/templates/initializer.rb.tt +84 -0
  153. data/lib/generators/protege/persona/persona_generator.rb +35 -0
  154. data/lib/generators/protege/persona/templates/persona.rb.tt +31 -0
  155. data/lib/generators/protege/postfix/postfix_generator.rb +74 -0
  156. data/lib/generators/protege/postfix/templates/deploy/mail/Dockerfile +43 -0
  157. data/lib/generators/protege/postfix/templates/deploy/mail/KeyTable +3 -0
  158. data/lib/generators/protege/postfix/templates/deploy/mail/MAIL.md +135 -0
  159. data/lib/generators/protege/postfix/templates/deploy/mail/README.md +56 -0
  160. data/lib/generators/protege/postfix/templates/deploy/mail/SigningTable +3 -0
  161. data/lib/generators/protege/postfix/templates/deploy/mail/TrustedHosts +8 -0
  162. data/lib/generators/protege/postfix/templates/deploy/mail/entrypoint.sh +118 -0
  163. data/lib/generators/protege/postfix/templates/deploy/mail/kamal-accessory.example.yml +32 -0
  164. data/lib/generators/protege/postfix/templates/deploy/mail/main.cf +68 -0
  165. data/lib/generators/protege/postfix/templates/deploy/mail/opendkim.conf +36 -0
  166. data/lib/generators/protege/postfix/templates/deploy/mail/opendmarc.conf +32 -0
  167. data/lib/generators/protege/postfix/templates/deploy/mail/opendmarc.ignore.hosts +9 -0
  168. data/lib/generators/protege/postfix/templates/deploy/mail/policyd-spf.conf +21 -0
  169. data/lib/generators/protege/postfix/templates/deploy/mail/provision.py +182 -0
  170. data/lib/generators/protege/postfix/templates/deploy/mail/relay-to-app.sh +28 -0
  171. data/lib/generators/protege/postfix/templates/github/workflows/build-mail-image.example.yml +50 -0
  172. data/lib/generators/protege/provider/provider_generator.rb +44 -0
  173. data/lib/generators/protege/provider/templates/provider.rb.tt +50 -0
  174. data/lib/generators/protege/resolver/resolver_generator.rb +34 -0
  175. data/lib/generators/protege/resolver/templates/resolver.rb.tt +31 -0
  176. data/lib/generators/protege/tool/templates/tool.rb.tt +50 -0
  177. data/lib/generators/protege/tool/tool_generator.rb +34 -0
  178. data/lib/protege/configuration.rb +171 -0
  179. data/lib/protege/engine.rb +103 -0
  180. data/lib/protege/errors/bad_request_error.rb +8 -0
  181. data/lib/protege/errors/contract_violation_error.rb +8 -0
  182. data/lib/protege/errors/error.rb +30 -0
  183. data/lib/protege/errors/generic_error.rb +7 -0
  184. data/lib/protege/errors/internal_error.rb +8 -0
  185. data/lib/protege/errors/invalid_model_error.rb +8 -0
  186. data/lib/protege/errors/invalid_tool_result_error.rb +9 -0
  187. data/lib/protege/errors/rate_limited_error.rb +8 -0
  188. data/lib/protege/errors/resolver_file_not_found_error.rb +9 -0
  189. data/lib/protege/errors/response_parse_failed_error.rb +8 -0
  190. data/lib/protege/errors/timeout_error.rb +8 -0
  191. data/lib/protege/errors/tool_not_available_error.rb +12 -0
  192. data/lib/protege/errors/tool_not_found_error.rb +9 -0
  193. data/lib/protege/errors/unauthorized_error.rb +8 -0
  194. data/lib/protege/errors/unavailable_error.rb +8 -0
  195. data/lib/protege/errors/unsupported_capability_error.rb +8 -0
  196. data/lib/protege/events/event.rb +98 -0
  197. data/lib/protege/events/inference_chunk_event.rb +17 -0
  198. data/lib/protege/events/inference_completed_event.rb +12 -0
  199. data/lib/protege/events/inference_failed_event.rb +12 -0
  200. data/lib/protege/events/inference_generated_event.rb +19 -0
  201. data/lib/protege/events/inference_max_turns_reached_event.rb +12 -0
  202. data/lib/protege/events/inference_started_event.rb +11 -0
  203. data/lib/protege/events/loop_run_completed_event.rb +11 -0
  204. data/lib/protege/events/loop_run_enqueued_event.rb +12 -0
  205. data/lib/protege/events/loop_run_failed_event.rb +12 -0
  206. data/lib/protege/events/loop_run_started_event.rb +11 -0
  207. data/lib/protege/events/tool_call_completed_event.rb +12 -0
  208. data/lib/protege/events/tool_call_failed_event.rb +12 -0
  209. data/lib/protege/events/tool_call_started_event.rb +11 -0
  210. data/lib/protege/events/tool_calls_received_event.rb +11 -0
  211. data/lib/protege/extensions/hook.rb +13 -0
  212. data/lib/protege/extensions/hook_mixin.rb +90 -0
  213. data/lib/protege/extensions/provider.rb +12 -0
  214. data/lib/protege/extensions/provider_mixin.rb +124 -0
  215. data/lib/protege/extensions/resolver.rb +12 -0
  216. data/lib/protege/extensions/resolver_mixin.rb +58 -0
  217. data/lib/protege/extensions/tool.rb +12 -0
  218. data/lib/protege/extensions/tool_mixin.rb +217 -0
  219. data/lib/protege/gateway/access_control.rb +66 -0
  220. data/lib/protege/gateway/access_policy.rb +94 -0
  221. data/lib/protege/gateway/attachment_policy.rb +115 -0
  222. data/lib/protege/gateway/mail/address.rb +145 -0
  223. data/lib/protege/gateway/mail/attachment.rb +17 -0
  224. data/lib/protege/gateway/mail/message_id.rb +104 -0
  225. data/lib/protege/gateway/mail/outbound.rb +181 -0
  226. data/lib/protege/gateway/mail/references.rb +107 -0
  227. data/lib/protege/gateway.rb +398 -0
  228. data/lib/protege/inference/provider/request.rb +110 -0
  229. data/lib/protege/inference/provider/response.rb +50 -0
  230. data/lib/protege/inference/provider/tool_call.rb +52 -0
  231. data/lib/protege/inference.rb +121 -0
  232. data/lib/protege/loop/schedule.rb +66 -0
  233. data/lib/protege/loop/scheduler.rb +42 -0
  234. data/lib/protege/loop.rb +37 -0
  235. data/lib/protege/orchestrator/context.rb +65 -0
  236. data/lib/protege/orchestrator/harness.rb +305 -0
  237. data/lib/protege/orchestrator/reply_context.rb +21 -0
  238. data/lib/protege/orchestrator/reply_harness.rb +68 -0
  239. data/lib/protege/orchestrator/resolver_chain.rb +93 -0
  240. data/lib/protege/orchestrator/responsibility_context.rb +22 -0
  241. data/lib/protege/orchestrator/responsibility_harness.rb +60 -0
  242. data/lib/protege/orchestrator.rb +37 -0
  243. data/lib/protege/subscribers/hook_dispatcher.rb +140 -0
  244. data/lib/protege/subscribers/tracing.rb +65 -0
  245. data/lib/protege/types/document_part.rb +13 -0
  246. data/lib/protege/types/image_part.rb +11 -0
  247. data/lib/protege/types/model_message.rb +40 -0
  248. data/lib/protege/types/result.rb +113 -0
  249. data/lib/protege/types/stored_file.rb +127 -0
  250. data/lib/protege/types/text_part.rb +8 -0
  251. data/lib/protege/types/tool_result.rb +16 -0
  252. data/lib/protege/version.rb +6 -0
  253. data/lib/protege.rb +47 -0
  254. data/lib/tasks/protege_tasks.rake +16 -0
  255. metadata +494 -0
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ module Gateway
5
+ # The inbound access guardrail: composes every access-policy *layer* that applies to a persona and
6
+ # answers the one question the Gateway asks — may this sender reach this persona? A gateway concern
7
+ # (inbound mail authorization), it lives beside the other Gateway protocol pieces and is called from
8
+ # the Gateway's inbound edge, +AgentMailbox+.
9
+ #
10
+ # Composition is by **intersection**: a sender is permitted only when *every* layer permits it
11
+ # (logical AND). Because intersection is commutative there is no precedence to reason about — the
12
+ # order of layers never changes the outcome — and the security property falls out for free: a layer
13
+ # can only *narrow* the set of admitted senders, never widen it. So a per-persona rule can tighten
14
+ # who reaches that agent but can never punch through the committed org-wide ceiling.
15
+ #
16
+ # Two layers exist today — the committed global policy (+Protege.configuration.inbound_access+) and
17
+ # the persona's runtime rules (+AccessRule.policy_for+). This composer is the single seam the rest of
18
+ # the engine calls; adding a layer later (e.g. a per-persona-class committed policy) is a one-line
19
+ # change to {#policies}, never a change at the call sites.
20
+ #
21
+ # @example Gate an inbound sender
22
+ # Gateway::AccessControl.for(persona:).permits?(address: mail.from.first)
23
+ class AccessControl
24
+ # @return [Protege::Persona] the persona the inbound mail is addressed to
25
+ attr_reader :persona
26
+
27
+ class << self
28
+ # Build the access control for a persona. Factory mirror of +.new+ reading as a lookup.
29
+ #
30
+ # @param persona [Protege::Persona] the routed persona
31
+ # @return [Protege::Gateway::AccessControl] the composed guardrail
32
+ def for(persona:)
33
+ new(persona:)
34
+ end
35
+ end
36
+
37
+ # @param persona [Protege::Persona] the routed persona
38
+ # @return [void]
39
+ def initialize(persona:)
40
+ @persona = persona
41
+ end
42
+
43
+ # Decide whether a sender may reach this persona, intersecting all applicable layers.
44
+ #
45
+ # @param address [String] the raw sender address
46
+ # @return [Boolean] true only when every layer permits the sender
47
+ def permits?(address:)
48
+ policies.all? { |policy| policy.permits?(address:) }
49
+ end
50
+
51
+ private
52
+
53
+ # The ordered (order-immaterial) list of policy layers that apply to this persona.
54
+ #
55
+ # Add a layer here — and only here — to extend the guardrail.
56
+ #
57
+ # @return [Array<Protege::Gateway::AccessPolicy>] the global ceiling and the persona's runtime policy
58
+ def policies
59
+ [
60
+ Protege.configuration.inbound_access,
61
+ AccessRule.policy_for(persona)
62
+ ]
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ module Gateway
5
+ # One layer of the inbound access-control guardrail: an immutable value that decides whether a given
6
+ # sender address is permitted, given an allow-list and a deny-list of address patterns. A Gateway
7
+ # value type (inbound authorization) — built through the +Gateway.build_access_policy+ factory,
8
+ # composed by +Gateway::AccessControl+, and assigned as the global ceiling via +config.inbound_access+
9
+ # or per-persona by +AccessRule.policy_for+:
10
+ #
11
+ # config.inbound_access = Protege::Gateway.build_access_policy(allow: ['*@company.co'])
12
+ #
13
+ # A policy is evaluated in a fixed precedence — an explicit +deny+ match always wins, then an
14
+ # +allow+ match admits, then the +default_decision+ settles everyone else. The default is *derived*
15
+ # when not given: the mere presence of an allow rule flips the layer into allow-list mode
16
+ # (default-deny), which is the intuitive reading of "only these may enter". With no allow rules the
17
+ # default is +:allow+, so a layer carrying only deny rules behaves as a block-list.
18
+ #
19
+ # Patterns are matched against the sender's tag-stripped, lowercased +local@domain+ routing key (so
20
+ # +ceo+finance@Company.CO+ matches a +ceo@company.co+ rule), and support a single +*+ wildcard
21
+ # (e.g. +*@company.co+). This object knows nothing about layering; composing several policies into
22
+ # the org→persona guardrail is +AccessControl+'s job.
23
+ #
24
+ # @example The identity policy — a bare +build_access_policy+ permits everyone (empty lists derive a
25
+ # default-allow), so it drops out of the +AccessControl+ intersection as a no-op.
26
+ AccessPolicy = Data.define(:allow, :deny, :default_decision) do
27
+ # Build a policy, normalizing the pattern lists and deriving the default decision.
28
+ #
29
+ # @param allow [Array<String>] allow-list patterns (blank/nil entries are dropped)
30
+ # @param deny [Array<String>] deny-list patterns (blank/nil entries are dropped)
31
+ # @param default_decision [Symbol, nil] +:allow+/+:deny+; when +nil+, derived from +allow+
32
+ # (present → +:deny+, empty → +:allow+)
33
+ def initialize(allow: [], deny: [], default_decision: nil)
34
+ allow = normalize_patterns(allow)
35
+ deny = normalize_patterns(deny)
36
+ # Derive the default decision when not given: if any allow rules exist, default-deny;
37
+ # otherwise default-allow.
38
+ default_decision ||= allow.empty? ? :allow : :deny
39
+
40
+ super
41
+ end
42
+
43
+ # Decide whether a sender address is permitted by this layer.
44
+ #
45
+ # @param address [String] the raw sender address (display name, plus-tag, and case tolerated)
46
+ # @return [Boolean] true when the sender may pass this layer
47
+ def permits?(address:)
48
+ key = routing_key_for(address)
49
+
50
+ return false if deny.any? { |pattern| matches?(pattern:, key:) }
51
+ return true if allow.any? { |pattern| matches?(pattern:, key:) }
52
+
53
+ default_decision == :allow
54
+ end
55
+
56
+ private
57
+
58
+ # Normalize a raw pattern list: stringify, strip, downcase, and drop blanks.
59
+ #
60
+ # @param patterns [Array<String, nil>] the raw patterns
61
+ # @return [Array<String>] the cleaned, lowercased patterns
62
+ def normalize_patterns(patterns)
63
+ Array(patterns).filter_map do |pattern|
64
+ cleaned = pattern.to_s.strip.downcase
65
+ cleaned unless cleaned.empty?
66
+ end
67
+ end
68
+
69
+ # Reduce a sender address to its tag-stripped, lowercased routing key.
70
+ #
71
+ # Delegates to +Gateway::Mail::Address+ so subaddressing and RFC 5322 forms line up with the
72
+ # routing used elsewhere; on a malformed address, falls back to a raw downcased comparison so a
73
+ # deny rule can still match obviously-bad input.
74
+ #
75
+ # @param address [String] the raw sender address
76
+ # @return [String] the comparison key
77
+ def routing_key_for(address)
78
+ Mail::Address.parse(address).routing_key
79
+ rescue ArgumentError
80
+ address.to_s.strip.downcase
81
+ end
82
+
83
+ # Test one pattern against one routing key, expanding a single +*+ wildcard.
84
+ #
85
+ # @param pattern [String] the normalized rule (may contain +*+)
86
+ # @param key [String] the normalized sender routing key
87
+ # @return [Boolean] true when the pattern matches the key
88
+ def matches?(pattern:, key:)
89
+ regex = Regexp.new("\\A#{Regexp.escape(pattern).gsub('\\*', '.*')}\\z")
90
+ regex.match?(key)
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ module Gateway
5
+ # Immutable attachment limits + the policy that enforces them. A Gateway value type consulted at the
6
+ # mail edges — built through the +Gateway.build_attachment_policy+ factory and assigned as
7
+ # +config.attachment_policy+:
8
+ #
9
+ # config.attachment_policy = Protege::Gateway.build_attachment_policy(max_bytes: 5.megabytes)
10
+ #
11
+ # A +Data+ value with the engine's default limits, consulted at the edges: the inbound mailbox
12
+ # bounces a violation, and the +send_email+ tool refuses one. +enabled+ is the master switch — when
13
+ # false, *any* attachment is rejected (inbound mail with attachments bounces; the agent can't attach
14
+ # files), while plain mail is unaffected. When enabled, limits are checked in a fixed order — count,
15
+ # then per-attachment size, then total size — and the first breach wins, so +#violation+ returns a
16
+ # single human-readable reason (or +nil+ when the set is acceptable).
17
+ AttachmentPolicy = Data.define(:enabled, :max_bytes, :max_count, :max_total_bytes) do
18
+ # @param enabled [Boolean] whether attachments are accepted at all (default true)
19
+ # @param max_count [Integer] maximum attachments per message (default 10)
20
+ # @param max_bytes [Integer] maximum bytes per attachment (default 10 MB)
21
+ # @param max_total_bytes [Integer] maximum combined bytes (default 25 MB)
22
+ def initialize(
23
+ enabled: true,
24
+ max_count: 10,
25
+ max_bytes: 10 * 1024 * 1024,
26
+ max_total_bytes: 25 * 1024 * 1024
27
+ )
28
+ super
29
+ end
30
+
31
+ # Report whether the given attachment sizes are acceptable.
32
+ #
33
+ # @param byte_sizes [Array<Integer>] each attachment's size in bytes
34
+ # @return [Boolean] true when the set is acceptable
35
+ def permits?(byte_sizes:)
36
+ violation(byte_sizes:).nil?
37
+ end
38
+
39
+ # Return the first reason the set is rejected, or +nil+ when acceptable. A message with no
40
+ # attachments is always acceptable, even when attachments are disabled.
41
+ #
42
+ # @param byte_sizes [Array<Integer>] each attachment's size in bytes
43
+ # @return [String, nil] the rejection reason, or nil
44
+ def violation(byte_sizes:)
45
+ sizes = Array(byte_sizes)
46
+
47
+ return nil if sizes.empty?
48
+
49
+ %i[
50
+ attachments_disabled?
51
+ too_many_attachments?
52
+ any_attachment_too_large?
53
+ total_size_exceeded?
54
+ ].each do |validator|
55
+ if (reason = send(validator, sizes))
56
+ return reason
57
+ end
58
+ end
59
+
60
+ nil
61
+ end
62
+
63
+ private
64
+
65
+ # Render a byte count as a rounded megabytes label for limit messages.
66
+ #
67
+ # @param bytes [Integer] the byte count
68
+ # @return [String] e.g. +"10 MB"+
69
+ def megabytes(bytes)
70
+ "#{(bytes / (1024.0 * 1024)).round} MB"
71
+ end
72
+
73
+ # Determines if attachments are enabled at all.
74
+ #
75
+ # @param _sizes [Array<Integer>] each attachment's size in bytes (ignored)
76
+ # @return [String, nil] a rejection reason when disabled, or +nil+ when enabled
77
+ # @param [Object] _sizes
78
+ def attachments_disabled?(_sizes)
79
+ return false if enabled
80
+
81
+ 'attachments are not accepted'
82
+ end
83
+
84
+ # Determines if there are too many attachments, based on the +max_count+ limit.
85
+ #
86
+ # @param sizes [Array<Integer>] each attachment's size in bytes
87
+ # @return [String, nil] a rejection reason when too many, or +nil+ when acceptable
88
+ def too_many_attachments?(sizes)
89
+ return false if sizes.size <= max_count
90
+
91
+ "too many attachments (max #{max_count})"
92
+ end
93
+
94
+ # Determines if any attachment exceeds the +max_bytes+ limit.
95
+ #
96
+ # @param sizes [Array<Integer>] each attachment's size in bytes
97
+ # @return [String, nil] a rejection reason when any attachment is too large, or +nil+ when acceptable
98
+ def any_attachment_too_large?(sizes)
99
+ return false if sizes.none? { _1 > max_bytes }
100
+
101
+ "an attachment exceeds the #{megabytes(max_bytes)} limit"
102
+ end
103
+
104
+ # Determines if the total of all attachments exceeds the +max_total_bytes+ limit.
105
+ #
106
+ # @param sizes [Array<Integer>] each attachment's size in bytes
107
+ # @return [String, nil] a rejection reason when the total size is exceeded, or +nil+ when acceptable
108
+ def total_size_exceeded?(sizes)
109
+ return false if sizes.sum <= max_total_bytes
110
+
111
+ "attachments exceed the #{megabytes(max_total_bytes)} total limit"
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mail'
4
+
5
+ module Protege
6
+ module Gateway
7
+ # Namespace for email-specific value objects — addresses, Message-IDs,
8
+ # and References chains — used when parsing inbound mail and building
9
+ # threaded replies. Pure lexical helpers with no transport concerns.
10
+ module Mail
11
+ # Value object representing an email address with optional plus-tag
12
+ # and optional display name.
13
+ #
14
+ # Instances are frozen. Local part, tag, and domain are lowercased.
15
+ # Construct via +.parse+, which delegates lexical parsing to
16
+ # +::Mail::Address+ (handling RFC 5322 oddities like quoted display
17
+ # names and comments) and then layers plus-tag splitting and the
18
+ # routing-key concern on top.
19
+ #
20
+ # Raises +ArgumentError+ when input is nil, blank, or has no +@+
21
+ # separator. Display name is excluded from equality — two addresses
22
+ # with the same +local+, +tag+, and +domain+ are equal regardless of
23
+ # the surrounding display name.
24
+ class Address
25
+ # Bare mailbox name with any plus-tag stripped, lowercased.
26
+ attr_reader :local
27
+
28
+ # Plus-tag suffix, or nil if no +tag was present in the source.
29
+ attr_reader :tag
30
+
31
+ # Domain part of the address, lowercased.
32
+ attr_reader :domain
33
+
34
+ # Display name from a +"Name" <addr>+ form, or nil if absent.
35
+ attr_reader :display_name
36
+
37
+ class << self
38
+ # Parse a raw address string into a normalized +Address+.
39
+ #
40
+ # Delegates the lexical parse to +::Mail::Address+ so quoted display names, comments, and
41
+ # other RFC 5322 syntax are handled, then splits any plus-tag off the local part and
42
+ # lowercases the local and domain.
43
+ #
44
+ # @param value [String] the raw address (optionally with display name and plus-tag).
45
+ # @return [Address] the normalized, frozen address.
46
+ # @raise [ArgumentError] when +value+ is nil/blank or has no +@+-separated mailbox.
47
+ def parse(value)
48
+ raise ArgumentError, 'address cannot be nil or blank' if value.nil? || value.to_s.strip.empty?
49
+
50
+ parsed = validate_mailbox(::Mail::Address.new(value.to_s.strip), value)
51
+ local, tag = split_plus_tag(parsed.local.downcase)
52
+ domain = parsed.domain.downcase
53
+ display_name = parsed.display_name
54
+
55
+ new(local:, tag:, domain:, display_name:)
56
+ end
57
+
58
+ private
59
+
60
+ # Ensure a parsed mailbox has both a local part and a domain.
61
+ #
62
+ # @param parsed [::Mail::Address] the result of the lexical parse.
63
+ # @param original [String] the original input, used in the error message.
64
+ # @return [::Mail::Address] +parsed+ unchanged when valid.
65
+ # @raise [ArgumentError] when the local part or domain is missing.
66
+ def validate_mailbox(parsed, original)
67
+ return parsed unless parsed.local.nil? ||
68
+ parsed.local.empty? ||
69
+ parsed.domain.nil? ||
70
+ parsed.domain.empty?
71
+
72
+ raise ArgumentError, "address missing '@': #{original.inspect}"
73
+ end
74
+
75
+ # Split a local part into its bare name and optional plus-tag.
76
+ #
77
+ # @param local_with_tag [String] the lowercased local part, possibly containing +'+'+.
78
+ # @return [Array(String, String), Array(String, nil)] the bare local and tag, or
79
+ # +[local, nil]+ when no plus-tag is present.
80
+ def split_plus_tag(local_with_tag)
81
+ plus_index = local_with_tag.index('+')
82
+ return [local_with_tag, nil] if plus_index.nil?
83
+
84
+ [
85
+ local_with_tag[0...plus_index],
86
+ local_with_tag[(plus_index + 1)..]
87
+ ]
88
+ end
89
+ end
90
+
91
+ # Wrap pre-parsed address parts and freeze. Prefer +.parse+.
92
+ #
93
+ # Blank display names are coerced to +nil+ so equality and rendering ignore them.
94
+ #
95
+ # @param local [String] the bare mailbox name, lowercased.
96
+ # @param domain [String] the domain, lowercased.
97
+ # @param tag [String, nil] the plus-tag, or +nil+.
98
+ # @param display_name [String, nil] the display name, or +nil+/blank to omit.
99
+ def initialize(local:, domain:, tag: nil, display_name: nil)
100
+ @local = local
101
+ @tag = tag
102
+ @domain = domain
103
+ @display_name = display_name.nil? || display_name.to_s.strip.empty? ? nil : display_name
104
+
105
+ freeze
106
+ end
107
+
108
+ # Return the tag-stripped +local@domain+ form used for routing.
109
+ #
110
+ # @return [String] the routing key with no plus-tag.
111
+ def routing_key
112
+ "#{@local}@#{@domain}"
113
+ end
114
+
115
+ # Return the canonical mailbox form, including the plus-tag when present.
116
+ #
117
+ # The display name is never included.
118
+ #
119
+ # @return [String] +local+tag@domain+ or +local@domain+.
120
+ def to_s
121
+ @tag ? "#{@local}+#{@tag}@#{@domain}" : "#{@local}@#{@domain}"
122
+ end
123
+
124
+ # Compare for equality by local, tag, and domain, ignoring display name.
125
+ #
126
+ # @param other [Object] the value to compare against.
127
+ # @return [Boolean] +true+ when +other+ is an +Address+ with matching parts.
128
+ def ==(other)
129
+ other.is_a?(Address) &&
130
+ other.local == @local &&
131
+ other.tag == @tag &&
132
+ other.domain == @domain
133
+ end
134
+ alias eql? ==
135
+
136
+ # Hash by local, tag, and domain so equal addresses hash alike.
137
+ #
138
+ # @return [Integer] the hash code.
139
+ def hash
140
+ [@local, @tag, @domain].hash
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ module Gateway
5
+ module Mail
6
+ # Value object for a file as a mail attachment — the typed payload +Outbound+ turns into a MIME
7
+ # part. It is the one shape the send path speaks: a filename, a content type, and the raw bytes on
8
+ # the wire. Both a reattached stored file (+Protege::StoredFile#to_mail_attachment+) and any
9
+ # freshly produced bytes become one of these, so the payload is defined in a single place rather
10
+ # than passed around as an anonymous Hash (mirroring how +::Mail+ is confined to +Outbound+).
11
+ #
12
+ # These bytes are for *transport* only; the file's durable identity remains its blob, which the
13
+ # outbound message attaches by reference on persistence.
14
+ Attachment = Data.define(:filename, :content_type, :bytes)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Protege
6
+ module Gateway
7
+ module Mail
8
+ # Value object representing an RFC 5322 Message-ID.
9
+ #
10
+ # Instances are frozen on construction; equality is based on the
11
+ # normalized string value. Construct via the factory methods —
12
+ # +.parse+, +.synthetic+, or +.ensure+ — rather than +.new+.
13
+ class MessageId
14
+ # Matches a leading or trailing angle bracket, used to strip stray brackets before re-wrapping.
15
+ EDGE_BRACKETS = /\A<|>\z/
16
+ private_constant :EDGE_BRACKETS
17
+
18
+ class << self
19
+ # Parse a raw value into a normalized, angle-bracketed +MessageId+.
20
+ #
21
+ # Trims surrounding whitespace and preserves case. Already-bracketed values pass through;
22
+ # half-bracketed values lose the stray bracket before being re-wrapped.
23
+ #
24
+ # @param value [String] the raw message-id, with or without brackets.
25
+ # @return [MessageId] the normalized message id.
26
+ def parse(value)
27
+ new(normalize(value))
28
+ end
29
+
30
+ # Build a unique synthetic +MessageId+ for inputs that lack one.
31
+ #
32
+ # The +domain+ should be the real sending domain for outbound mail, so the Message-ID is
33
+ # well-formed and recipients (e.g. Gmail) trust it and thread replies. It defaults to
34
+ # +protege.local+ only for the inbound/console fallback case, where the id is internal and
35
+ # never travels back out.
36
+ #
37
+ # @param domain [String] the domain for the id's right-hand side.
38
+ # @return [MessageId] a fresh +<synthetic.UUID@domain>+ id.
39
+ def synthetic(domain: 'protege.local')
40
+ new("<synthetic.#{SecureRandom.uuid}@#{domain}>")
41
+ end
42
+
43
+ # Parse +value+, or fall back to a synthetic id when it is missing.
44
+ #
45
+ # @param value [String, nil] the raw message-id, possibly nil or blank.
46
+ # @return [MessageId] the parsed id, or a synthetic one when +value+ is nil/blank.
47
+ def ensure(value)
48
+ return synthetic if value.nil? || value.to_s.strip.empty?
49
+
50
+ parse(value)
51
+ end
52
+
53
+ private
54
+
55
+ # Normalize a raw value to a trimmed, angle-bracketed string.
56
+ #
57
+ # @param value [String] the raw message-id.
58
+ # @return [String] the value wrapped in a single pair of angle brackets.
59
+ def normalize(value)
60
+ trimmed = value.to_s.strip
61
+ return trimmed if trimmed.start_with?('<') && trimmed.end_with?('>')
62
+
63
+ "<#{trimmed.gsub(EDGE_BRACKETS, '')}>"
64
+ end
65
+ end
66
+
67
+ # Normalized message-id string (always angle-bracketed).
68
+ attr_reader :value
69
+
70
+ # Wrap a pre-normalized value and freeze. Prefer the factory methods.
71
+ #
72
+ # @param value [String] an already-normalized, angle-bracketed message-id.
73
+ def initialize(value)
74
+ @value = value
75
+ freeze
76
+ end
77
+
78
+ # Compare by normalized value.
79
+ #
80
+ # @param other [Object] the value to compare against.
81
+ # @return [Boolean] +true+ when +other+ is a +MessageId+ with the same value.
82
+ def ==(other)
83
+ other.is_a?(MessageId) && @value == other.value
84
+ end
85
+
86
+ alias eql? ==
87
+
88
+ # Hash by normalized value so a +MessageId+ works as a Hash key.
89
+ #
90
+ # @return [Integer] the hash code.
91
+ def hash
92
+ @value.hash
93
+ end
94
+
95
+ # Return the normalized string value.
96
+ #
97
+ # @return [String] the angle-bracketed message-id.
98
+ def to_s
99
+ @value
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end