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,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Per-persona tool scoping — mixed into +Persona+ to bound which tools an agent may use.
5
+ #
6
+ # A persona's effective tools are the code-declared *grant* (the ceiling, set by the {ClassMethods#tools}
7
+ # macro) minus the operator's +disabled_tool_ids+ override, and never wider than the grant. Two layers,
8
+ # deliberately different in reach:
9
+ #
10
+ # * The grant lives on a +class_attribute+ so it survives Active Record's schema-loading reloads (the
11
+ # same reason +Persona._display_name+ is one). Unlike the resolver chains — per-subclass +@ivar+s that
12
+ # never inherit — the grant *inherits* down the STI tree, so a base persona narrows a whole family's
13
+ # ceiling unless a subclass declares its own. A nil grant means "every registered tool", so an
14
+ # unconfigured persona keeps the full catalogue (the pre-scoping behaviour).
15
+ # * +disabled_tool_ids+ is a dashboard-editable JSON array on the row (text column; SQLite has no native
16
+ # JSON type), normalised to a clean string array so it is never nil. It can only *subtract* from the
17
+ # grant, never add to it.
18
+ #
19
+ # {#available_tools} resolves both layers against the live registry, so a stale grant or disabled id —
20
+ # naming a tool since deleted from the code — is simply skipped rather than conjuring or breaking a tool.
21
+ module ToolScoped
22
+ extend ActiveSupport::Concern
23
+
24
+ included do
25
+ serialize :disabled_tool_ids, coder: JSON
26
+ before_validation :normalize_disabled_tool_ids
27
+
28
+ # Backing store for the code-declared grant (tool ids, or nil for "all tools"). Inherits down STI.
29
+ class_attribute :_tool_grant, instance_accessor: false
30
+ end
31
+
32
+ # Class-level DSL for declaring the grant.
33
+ class_methods do
34
+ # Declare the tools this persona subclass may use — the code-side ceiling of its tool scope.
35
+ #
36
+ # A DSL macro (positional by convention, like +message_resolver+): given tool ids it records them as
37
+ # symbols. Omitting it leaves the grant nil ("every registered tool"). The operator can only narrow
38
+ # this grant at runtime via +disabled_tool_ids+, never widen it.
39
+ #
40
+ # @param ids [Array<Symbol, String>] the tool ids the persona is allowed to use
41
+ # @return [Array<Symbol>] the recorded grant
42
+ def tools(*ids)
43
+ self._tool_grant = ids.map(&:to_sym)
44
+ end
45
+
46
+ # Return this subclass's declared tool grant, or nil when none was declared ("all tools").
47
+ #
48
+ # @return [Array<Symbol>, nil] the granted tool ids, or nil for the full catalogue
49
+ def tool_grant
50
+ _tool_grant
51
+ end
52
+ end
53
+
54
+ # Return the tool classes this persona may currently use — its effective, scoped catalogue.
55
+ #
56
+ # Computed as one filter over the live registry (+ToolMixin.registered+): drop anything the operator
57
+ # has switched off in +disabled_tool_ids+, then keep only what the subclass's grant permits (or
58
+ # everything, when the grant is nil). Iterating the registry rather than the grant means a grant id
59
+ # with no matching registered tool is simply skipped — a stale scope can never conjure or break a tool.
60
+ #
61
+ # @return [Array<Class>] the tool classes offered to the model for this persona
62
+ def available_tools
63
+ disabled = disabled_tool_ids.map(&:to_sym)
64
+ grant = self.class.tool_grant
65
+
66
+ ToolMixin.registered
67
+ .reject { |tool| disabled.include?(tool.id) }
68
+ .select { |tool| grant.nil? || grant.include?(tool.id) }
69
+ end
70
+
71
+ # The ids of this persona's effective tools — a convenience over {#available_tools} for UI and tests.
72
+ #
73
+ # @return [Array<Symbol>] the effective tool ids
74
+ def available_tool_ids
75
+ available_tools.map(&:id)
76
+ end
77
+
78
+ private
79
+
80
+ # Coerce +disabled_tool_ids+ to a deduped, blank-stripped array of strings so the JSON column stays
81
+ # clean regardless of how it was assigned (symbols, blanks, nils, duplicates) and is never nil.
82
+ #
83
+ # @return [void]
84
+ def normalize_disabled_tool_ids
85
+ self.disabled_tool_ids = Array(disabled_tool_ids).map(&:to_s).reject(&:blank?).uniq
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # One runtime allow/deny rule for a single persona — the dashboard-editable layer of the inbound
5
+ # access-control guardrail. Each record carries a +kind+ (+allow+/+deny+) and an address +pattern+
6
+ # (an exact address or a +*+ wildcard such as +*@company.co+); together a persona's rules fold into
7
+ # a +Gateway::AccessPolicy+ via {.policy_for}.
8
+ #
9
+ # This is the *narrowing* layer: +AccessControl+ intersects it with the committed global policy
10
+ # (+Protege.configuration.inbound_access+), so a persona's rules can only further restrict who may
11
+ # reach that agent — never widen past the org-wide ceiling. A persona with no rules imposes no
12
+ # constraint of its own.
13
+ class AccessRule < ApplicationRecord
14
+ # ── Associations ────────────────────────────────────────────────────────
15
+
16
+ belongs_to :persona, class_name: 'Protege::Persona'
17
+
18
+ # ── Attributes ──────────────────────────────────────────────────────────
19
+
20
+ # Whether this rule admits (+allow+) or blocks (+deny+) matching senders.
21
+ enum :kind, { allow: 'allow', deny: 'deny' }
22
+
23
+ # ── Validations ─────────────────────────────────────────────────────────
24
+
25
+ # A rule with no pattern matches nothing and is meaningless; the address pattern is required.
26
+ validates :pattern, presence: true
27
+
28
+ # ── Class methods ───────────────────────────────────────────────────────
29
+
30
+ class << self
31
+ # Fold one persona's rules into a single +Gateway::AccessPolicy+.
32
+ #
33
+ # Groups the persona's +allow+/+deny+ patterns into the policy's lists; the policy then derives
34
+ # its own default (an allow-list implies default-deny). A persona with no rules yields a bare
35
+ # +Gateway::AccessPolicy+, which permits everyone and so drops out of the +AccessControl+
36
+ # intersection.
37
+ #
38
+ # @param persona [Protege::Persona] the persona whose rules to fold
39
+ # @return [Protege::Gateway::AccessPolicy] the persona's runtime access policy
40
+ def policy_for(persona)
41
+ grouped = where(persona:).group_by(&:kind)
42
+
43
+ Gateway.build_access_policy(
44
+ allow: Array(grouped['allow']).map(&:pattern),
45
+ deny: Array(grouped['deny']).map(&:pattern)
46
+ )
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Abstract base class for every Active Record model in the Protege engine.
5
+ #
6
+ # Every persisted model — +Persona+, +Message+, +EmailThread+, +EmailDomain+ — inherits
7
+ # from this class rather than +ActiveRecord::Base+ directly, mirroring the convention a host
8
+ # Rails application uses for its own +ApplicationRecord+. Keeping a dedicated base class gives
9
+ # the engine a single seam for shared concerns (connection handling, common scopes, callbacks)
10
+ # without leaking configuration into the host application's models.
11
+ #
12
+ # The class is marked abstract so Active Record never expects a +protege_application_records+
13
+ # table to exist.
14
+ class ApplicationRecord < ActiveRecord::Base
15
+ # Declare this class abstract so Active Record treats it as a base only, never a table.
16
+ self.abstract_class = true
17
+ end
18
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Thread-local, per-run state for a single Protege processing pass.
5
+ #
6
+ # Subclasses +ActiveSupport::CurrentAttributes+, so every attribute declared here is reset
7
+ # automatically at the start of each web request and each Active Job execution. Values must
8
+ # therefore be set explicitly at the origin of each run and are never carried implicitly from
9
+ # one run to the next.
10
+ #
11
+ # Within the LOGI pipeline this is the carrier for the correlation ID that ties a single inbound
12
+ # email to every downstream event. +AgentMailbox#process+ sets +correlation_id+ from the inbound
13
+ # email's RFC 2822 Message-ID; +InferenceJob#perform+ and +ConsoleInferenceJob#perform+ restore
14
+ # it at the top of the job. This lets the trace token flow from Gateway ingress through Inference
15
+ # without being threaded through every method signature.
16
+ #
17
+ # @example Set the correlation ID at the ingress boundary
18
+ # Protege::Current.correlation_id = mail.message_id
19
+ class Current < ActiveSupport::CurrentAttributes
20
+ # Trace token shared by all events emitted during one processing run; the inbound Message-ID.
21
+ attribute :correlation_id
22
+ end
23
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'base64'
5
+
6
+ module Protege
7
+ # A mail domain the Protege engine is configured to receive email for.
8
+ #
9
+ # Each domain owns a 2048-bit RSA keypair, generated automatically on create and stored as PEM in
10
+ # +dkim_private_key_pem+; the public half is derived on demand for the DKIM DNS record. The model
11
+ # exists to drive the domain show page, which surfaces the four DNS records an operator must
12
+ # publish before the domain can receive and authenticate mail: MX (inbound routing), SPF
13
+ # (envelope sender authorisation), DKIM (signing key), and DMARC (alignment policy). Rotating the
14
+ # keypair therefore requires republishing the DKIM TXT record.
15
+ class EmailDomain < ApplicationRecord
16
+ self.table_name = 'protege_email_domains'
17
+
18
+ # ── Concerns ──────────────────────────────────────────────────────────────
19
+
20
+ # Keep the Domains sidebar live (append/replace/remove).
21
+ include BroadcastableEmailDomain
22
+
23
+ # ── Validations ─────────────────────────────────────────────────────────
24
+
25
+ validates :domain, presence: true, uniqueness: true
26
+
27
+ # ── Scopes ──────────────────────────────────────────────────────────────
28
+
29
+ # Domains ordered by name, as shown in UI selectors.
30
+ default_scope { order(:domain) }
31
+
32
+ # ── Callbacks ───────────────────────────────────────────────────────────
33
+
34
+ # Generate the DKIM keypair before the record is first validated.
35
+ before_validation :generate_dkim_keypair, on: :create
36
+
37
+ # Re-provision the self-hosted MTA after any change so its signing keys/tables track the DB. The
38
+ # push happens off the request via +MailProvisioningJob+; a no-op in provider mode.
39
+ after_commit :provision_mail_server, on: %i[create update destroy]
40
+
41
+ # ── Class methods ─────────────────────────────────────────────────────────
42
+
43
+ class << self
44
+ # Report whether Protege receives mail for the given address's domain — i.e. whether it belongs
45
+ # to a registered +EmailDomain+. Drives the Action Mailbox routing predicate (+Gateway.claims?+)
46
+ # so the engine claims only its own inbound mail; until a domain is added, nothing routes.
47
+ # Parsing is delegated to +Gateway::Mail::Address+; blank or malformed input is not ours.
48
+ #
49
+ # @param address [String, nil] a recipient address from an inbound email
50
+ # @return [Boolean] true when the address's domain is one Protege receives for
51
+ def receives?(address)
52
+ exists?(domain: Gateway::Mail::Address.parse(address).domain)
53
+ rescue ArgumentError
54
+ false
55
+ end
56
+ end
57
+
58
+ # ── DNS record helpers ───────────────────────────────────────────────────
59
+
60
+ # Return the DNS hostname for the DKIM TXT record: +<selector>._domainkey.<domain>+.
61
+ #
62
+ # @return [String] the fully qualified DKIM record name
63
+ def dkim_dns_name
64
+ "#{dkim_selector}._domainkey.#{domain}"
65
+ end
66
+
67
+ # Return the Base64-encoded DER public key for the DKIM TXT record value.
68
+ #
69
+ # Derives the public key from the stored private PEM each call, so it always matches the
70
+ # current keypair.
71
+ #
72
+ # @return [String] the strict-Base64-encoded public key
73
+ def dkim_public_key_base64
74
+ key = OpenSSL::PKey::RSA.new(dkim_private_key_pem)
75
+ Base64.strict_encode64(key.public_key.to_der)
76
+ end
77
+
78
+ # Return the MX record value pointing inbound mail at this server.
79
+ #
80
+ # @return [String] the MX record value, with priority 10
81
+ def mx_record
82
+ "10 #{domain}."
83
+ end
84
+
85
+ # Return the SPF TXT record value for the domain apex.
86
+ #
87
+ # @return [String] an SPF policy authorising the domain's MX hosts
88
+ def spf_txt_record
89
+ 'v=spf1 mx ~all'
90
+ end
91
+
92
+ # Return the DKIM TXT record value published at {#dkim_dns_name}.
93
+ #
94
+ # @return [String] the DKIM record carrying the RSA public key
95
+ def dkim_txt_record
96
+ "v=DKIM1; k=rsa; p=#{dkim_public_key_base64}"
97
+ end
98
+
99
+ # Return the DMARC TXT record value published at +_dmarc.<domain>+.
100
+ #
101
+ # @return [String] a strict-alignment, reject-policy DMARC record
102
+ def dmarc_txt_record
103
+ "v=DMARC1; p=reject; adkim=s; aspf=s; rua=mailto:dmarc@#{domain}"
104
+ end
105
+
106
+ # ── MTA provisioning ──────────────────────────────────────────────────────
107
+
108
+ # The minimal signing material the self-hosted MTA needs to sign this domain's outbound mail — the
109
+ # domain, its DKIM selector, and the private key. One entry in +Gateway.mail_provisioning_manifest+,
110
+ # which the app pushes to the mail accessory's control listener whenever a domain changes.
111
+ #
112
+ # @return [Hash] the domain's provisioning entry
113
+ def to_provisioning
114
+ { domain:, selector: dkim_selector, private_key_pem: dkim_private_key_pem }
115
+ end
116
+
117
+ private
118
+
119
+ # Generate and store a fresh RSA-2048 keypair on create.
120
+ #
121
+ # Idempotent: assigns the PEM only when one is not already present, so re-running validation
122
+ # never overwrites an existing key.
123
+ #
124
+ # @return [void]
125
+ def generate_dkim_keypair
126
+ self.dkim_private_key_pem ||= OpenSSL::PKey::RSA.generate(2048).to_pem
127
+ end
128
+
129
+ # Enqueue a push of the whole domain manifest to the self-hosted MTA accessory.
130
+ #
131
+ # @return [void]
132
+ def provision_mail_server
133
+ MailProvisioningJob.perform_later
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Groups every +Message+ belonging to a single email conversation.
5
+ #
6
+ # A thread is identified by its canonical +thread_id+: the RFC 2822 Message-ID of the conversation
7
+ # root. For a reply that is the first entry of the References chain (falling back to In-Reply-To);
8
+ # for a brand-new conversation it is the message's own Message-ID. Threads belong to one +Persona+
9
+ # and carry denormalised counters (+message_count+, +last_message_at+) that +Message+ keeps fresh
10
+ # so the inbox can sort and display without aggregating on every render.
11
+ class EmailThread < ApplicationRecord
12
+ # ── Concerns ────────────────────────────────────────────────────────────
13
+
14
+ include BroadcastableThread
15
+
16
+ # ── Associations ────────────────────────────────────────────────────────
17
+
18
+ belongs_to :persona, class_name: 'Protege::Persona'
19
+
20
+ # Destroying a thread removes its messages; inverse lets Message keep counters in sync. Each message
21
+ # in turn destroys its own recorded tool uses (see +Message+), so a thread's tool history is torn
22
+ # down with it.
23
+ has_many :messages, dependent: :destroy, inverse_of: :email_thread
24
+
25
+ # ── Scopes ──────────────────────────────────────────────────────────────
26
+
27
+ # Eager-load persona and messages, most-recently-active first — the inbox listing query.
28
+ scope :for_inbox, -> { includes(:persona, :messages).order(last_message_at: :desc) }
29
+
30
+ # ── Validations ─────────────────────────────────────────────────────────
31
+
32
+ with_options presence: true do
33
+ # The canonical Message-ID must be unique so each conversation maps to one thread.
34
+ validates :thread_id, uniqueness: true
35
+ validates :persona
36
+ end
37
+
38
+ # ── Instance methods ─────────────────────────────────────────────────────
39
+
40
+ # Return the most recently sent message in the thread.
41
+ #
42
+ # @return [Protege::Message, nil] the latest message, or nil for an empty thread
43
+ def latest_message
44
+ messages.order(:sent_at).last
45
+ end
46
+
47
+ # ── Class methods ───────────────────────────────────────────────────────
48
+
49
+ class << self
50
+ # Derive the canonical RFC 2822 thread-root ID from a mail object.
51
+ #
52
+ # Checks the References chain first (its head is the conversation root), then In-Reply-To,
53
+ # then falls back to the message's own Message-ID for a new conversation. The result is
54
+ # normalized through +Gateway::Mail+ so every thread id is stored in canonical angle-bracketed
55
+ # form regardless of how the raw header was punctuated.
56
+ #
57
+ # @param mail [Mail::Message] the email to inspect
58
+ # @return [String] the canonical, angle-bracketed thread identifier
59
+ def thread_id_for(mail)
60
+ Gateway.reference_ids(mail.references).first ||
61
+ Gateway.canonical_message_id(mail.in_reply_to.presence || mail.message_id)
62
+ end
63
+
64
+ # Find or create the thread for the given mail and persona.
65
+ #
66
+ # On creation, seeds the persona, subject, and last-activity timestamp from the mail.
67
+ #
68
+ # @param mail [Mail::Message] the email being threaded
69
+ # @param persona [Protege::Persona] the persona that owns the conversation
70
+ # @return [Protege::EmailThread] the existing or newly created thread
71
+ # @raise [ActiveRecord::RecordInvalid] when a new thread fails validation
72
+ def find_or_create_for(mail:, persona:)
73
+ find_or_create_by!(thread_id: thread_id_for(mail)) do |t|
74
+ t.persona = persona
75
+ t.subject = mail.subject.to_s
76
+ t.last_message_at = Time.current
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end