insika 0.0.1 → 0.2.0

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 (277) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +361 -0
  3. data/LICENSE +21 -0
  4. data/README.md +136 -2
  5. data/bin/insika +366 -0
  6. data/docs/AGENTS.md +618 -0
  7. data/docs/ARCHITECTURE.md +333 -0
  8. data/docs/BENCHMARK.md +114 -0
  9. data/docs/CHANNELS.md +453 -0
  10. data/docs/CONTEXT.md +117 -0
  11. data/docs/DEPLOY.md +354 -0
  12. data/docs/EMBEDDING.md +198 -0
  13. data/docs/EVALS.md +273 -0
  14. data/docs/LOADTEST.md +232 -0
  15. data/docs/OBSERVABILITY.md +374 -0
  16. data/docs/PLUGINS.md +211 -0
  17. data/docs/REFINEMENT.md +477 -0
  18. data/docs/RELEASING.md +70 -0
  19. data/docs/RUNNING-LOCAL.md +153 -0
  20. data/docs/SANDBOX.md +114 -0
  21. data/docs/SECURITY.md +375 -0
  22. data/docs/SKILLS.md +284 -0
  23. data/docs/TOOLS.md +302 -0
  24. data/docs/WHY.md +137 -0
  25. data/docs/WORKFLOWS.md +225 -0
  26. data/docs/build.md +14 -0
  27. data/docs/index.md +68 -0
  28. data/docs/onboarding/start.md +126 -0
  29. data/docs/operate.md +12 -0
  30. data/docs/ship.md +10 -0
  31. data/docs/understand.md +10 -0
  32. data/lib/insika/agent_file_store.rb +125 -0
  33. data/lib/insika/agent_profile.rb +255 -0
  34. data/lib/insika/alert_dispatcher.rb +139 -0
  35. data/lib/insika/allowlist.rb +28 -0
  36. data/lib/insika/baseline_store.rb +74 -0
  37. data/lib/insika/budget_ledger.rb +135 -0
  38. data/lib/insika/capability/resolved_tool.rb +34 -0
  39. data/lib/insika/capability_registry.rb +112 -0
  40. data/lib/insika/channel_delivery.rb +153 -0
  41. data/lib/insika/channel_registry.rb +30 -0
  42. data/lib/insika/channels/relay.rb +178 -0
  43. data/lib/insika/channels/web/widget.js +283 -0
  44. data/lib/insika/channels/web.rb +211 -0
  45. data/lib/insika/channels/webhook.rb +58 -0
  46. data/lib/insika/chat_builder.rb +303 -0
  47. data/lib/insika/checkpoint.rb +13 -0
  48. data/lib/insika/checkpoint_store.rb +153 -0
  49. data/lib/insika/circuit_state.rb +114 -0
  50. data/lib/insika/coercion.rb +58 -0
  51. data/lib/insika/command.rb +32 -0
  52. data/lib/insika/command_bus.rb +39 -0
  53. data/lib/insika/commands/agent_payload.rb +43 -0
  54. data/lib/insika/commands/approve_action.rb +46 -0
  55. data/lib/insika/commands/cancel_task.rb +33 -0
  56. data/lib/insika/commands/create_agent.rb +54 -0
  57. data/lib/insika/commands/create_session.rb +67 -0
  58. data/lib/insika/commands/delete_agent.rb +33 -0
  59. data/lib/insika/commands/delete_agent_file.rb +50 -0
  60. data/lib/insika/commands/delete_data_tool.rb +33 -0
  61. data/lib/insika/commands/delete_llm_provider.rb +36 -0
  62. data/lib/insika/commands/delete_mcp.rb +30 -0
  63. data/lib/insika/commands/delete_skill.rb +43 -0
  64. data/lib/insika/commands/delete_system_file.rb +29 -0
  65. data/lib/insika/commands/gate_refinement.rb +245 -0
  66. data/lib/insika/commands/import_mcp_tools.rb +48 -0
  67. data/lib/insika/commands/import_tools.rb +81 -0
  68. data/lib/insika/commands/issue_tenant_token.rb +41 -0
  69. data/lib/insika/commands/memory_add_note.rb +32 -0
  70. data/lib/insika/commands/memory_forget_fact.rb +32 -0
  71. data/lib/insika/commands/memory_put_fact.rb +35 -0
  72. data/lib/insika/commands/pause_task.rb +29 -0
  73. data/lib/insika/commands/resolve_refinement.rb +126 -0
  74. data/lib/insika/commands/restore_agent_file.rb +36 -0
  75. data/lib/insika/commands/restore_data_tool.rb +34 -0
  76. data/lib/insika/commands/restore_system_file.rb +31 -0
  77. data/lib/insika/commands/resume_task.rb +85 -0
  78. data/lib/insika/commands/revoke_token.rb +39 -0
  79. data/lib/insika/commands/rotate_tenant_token.rb +43 -0
  80. data/lib/insika/commands/run_refinement.rb +133 -0
  81. data/lib/insika/commands/send_message.rb +150 -0
  82. data/lib/insika/commands/set_agent_tools.rb +39 -0
  83. data/lib/insika/commands/set_skill_agents.rb +112 -0
  84. data/lib/insika/commands/trigger_workflow.rb +80 -0
  85. data/lib/insika/commands/update_agent.rb +49 -0
  86. data/lib/insika/commands/update_settings.rb +33 -0
  87. data/lib/insika/commands/upsert_llm_provider.rb +34 -0
  88. data/lib/insika/commands/upsert_mcp.rb +32 -0
  89. data/lib/insika/commands/write_agent_file.rb +57 -0
  90. data/lib/insika/commands/write_data_tool.rb +43 -0
  91. data/lib/insika/commands/write_golden.rb +58 -0
  92. data/lib/insika/commands/write_skill.rb +60 -0
  93. data/lib/insika/commands/write_system_file.rb +31 -0
  94. data/lib/insika/config_store.rb +89 -0
  95. data/lib/insika/context/builder.rb +166 -0
  96. data/lib/insika/context/catalog_provider.rb +23 -0
  97. data/lib/insika/context/fragment.rb +43 -0
  98. data/lib/insika/context/priority.rb +30 -0
  99. data/lib/insika/context/provider.rb +19 -0
  100. data/lib/insika/context/providers/memory.rb +60 -0
  101. data/lib/insika/context/providers/prompt.rb +105 -0
  102. data/lib/insika/context/providers/request.rb +32 -0
  103. data/lib/insika/context/providers/session.rb +123 -0
  104. data/lib/insika/context/providers/skill.rb +24 -0
  105. data/lib/insika/context/providers/skill_trigger.rb +128 -0
  106. data/lib/insika/context/providers/tool_search.rb +20 -0
  107. data/lib/insika/context_trace_store.rb +92 -0
  108. data/lib/insika/delegation_store.rb +153 -0
  109. data/lib/insika/doctor.rb +539 -0
  110. data/lib/insika/dsl/definition.rb +55 -0
  111. data/lib/insika/dsl/runtime.rb +382 -0
  112. data/lib/insika/dsl/server_boot.rb +98 -0
  113. data/lib/insika/dsl/system.rb +93 -0
  114. data/lib/insika/dsl/workflow_adapter.rb +59 -0
  115. data/lib/insika/dsl.rb +364 -0
  116. data/lib/insika/edge_limiter.rb +268 -0
  117. data/lib/insika/egress_guard.rb +75 -0
  118. data/lib/insika/env_schema.rb +249 -0
  119. data/lib/insika/errors.rb +201 -0
  120. data/lib/insika/evals/assertions.rb +247 -0
  121. data/lib/insika/evals/baseline.rb +69 -0
  122. data/lib/insika/evals/golden.rb +172 -0
  123. data/lib/insika/evals/judge.rb +225 -0
  124. data/lib/insika/evals/pairwise.rb +178 -0
  125. data/lib/insika/evals/report.rb +115 -0
  126. data/lib/insika/evals/runner.rb +141 -0
  127. data/lib/insika/evals/transport.rb +178 -0
  128. data/lib/insika/event.rb +18 -0
  129. data/lib/insika/event_stream.rb +132 -0
  130. data/lib/insika/executor.rb +1995 -0
  131. data/lib/insika/frontmatter.rb +42 -0
  132. data/lib/insika/golden_store.rb +145 -0
  133. data/lib/insika/hooks.rb +48 -0
  134. data/lib/insika/http_client.rb +63 -0
  135. data/lib/insika/inbound_log.rb +84 -0
  136. data/lib/insika/llm_configurator.rb +99 -0
  137. data/lib/insika/llm_provider_store.rb +83 -0
  138. data/lib/insika/loop_detector.rb +143 -0
  139. data/lib/insika/mcp_http_client.rb +67 -0
  140. data/lib/insika/mcp_store.rb +115 -0
  141. data/lib/insika/mcp_tool_ingestor.rb +143 -0
  142. data/lib/insika/memory_store.rb +93 -0
  143. data/lib/insika/message_origin.rb +76 -0
  144. data/lib/insika/middleware.rb +36 -0
  145. data/lib/insika/model_policy.rb +52 -0
  146. data/lib/insika/model_resolver.rb +176 -0
  147. data/lib/insika/model_selection.rb +115 -0
  148. data/lib/insika/onboarding.rb +208 -0
  149. data/lib/insika/outbox_store.rb +166 -0
  150. data/lib/insika/overlay_tool_registry.rb +102 -0
  151. data/lib/insika/pack.rb +102 -0
  152. data/lib/insika/pack_importer.rb +123 -0
  153. data/lib/insika/pending_action_store.rb +120 -0
  154. data/lib/insika/plugin/loader.rb +356 -0
  155. data/lib/insika/plugin.rb +35 -0
  156. data/lib/insika/policy/engine.rb +83 -0
  157. data/lib/insika/policy/policy.rb +120 -0
  158. data/lib/insika/policy_registry.rb +23 -0
  159. data/lib/insika/profile_source.rb +143 -0
  160. data/lib/insika/prompt_catalog.rb +61 -0
  161. data/lib/insika/provider_error_classifier.rb +160 -0
  162. data/lib/insika/queue_policy.rb +167 -0
  163. data/lib/insika/recovery.rb +168 -0
  164. data/lib/insika/refinement/candidate.rb +159 -0
  165. data/lib/insika/refinement/evidence_collector.rb +371 -0
  166. data/lib/insika/refinement/gate.rb +234 -0
  167. data/lib/insika/refinement/panel.rb +222 -0
  168. data/lib/insika/refinement/proposer.rb +262 -0
  169. data/lib/insika/refinement_store.rb +295 -0
  170. data/lib/insika/registry.rb +59 -0
  171. data/lib/insika/reliability.rb +185 -0
  172. data/lib/insika/safety/config.rb +109 -0
  173. data/lib/insika/safety/detectors.rb +176 -0
  174. data/lib/insika/safety/factory.rb +102 -0
  175. data/lib/insika/safety/input_guardrail.rb +102 -0
  176. data/lib/insika/safety/moderator.rb +94 -0
  177. data/lib/insika/safety/output_filter.rb +79 -0
  178. data/lib/insika/safety/output_validator.rb +101 -0
  179. data/lib/insika/safety/safe_responses.rb +47 -0
  180. data/lib/insika/sandbox/boundary.rb +93 -0
  181. data/lib/insika/sandbox/docker.rb +74 -0
  182. data/lib/insika/sandbox/local.rb +33 -0
  183. data/lib/insika/sandbox/runner.rb +80 -0
  184. data/lib/insika/sandbox.rb +85 -0
  185. data/lib/insika/schema_guard.rb +147 -0
  186. data/lib/insika/secret_masking.rb +34 -0
  187. data/lib/insika/server/a2a/agent_card.rb +27 -0
  188. data/lib/insika/server/a2a/app.rb +112 -0
  189. data/lib/insika/server/a2a/client.rb +101 -0
  190. data/lib/insika/server/a2a/errors.rb +32 -0
  191. data/lib/insika/server/a2a/http.rb +42 -0
  192. data/lib/insika/server/a2a/message.rb +27 -0
  193. data/lib/insika/server/a2a/protocol.rb +45 -0
  194. data/lib/insika/server/a2a/remotes.rb +25 -0
  195. data/lib/insika/server/a2a/task_projection.rb +40 -0
  196. data/lib/insika/server/app.rb +1022 -0
  197. data/lib/insika/server/boot.rb +119 -0
  198. data/lib/insika/server/rack_app.rb +118 -0
  199. data/lib/insika/server/responses.rb +165 -0
  200. data/lib/insika/server/sse_body.rb +96 -0
  201. data/lib/insika/server/tenant_auth.rb +61 -0
  202. data/lib/insika/session_actor.rb +162 -0
  203. data/lib/insika/session_store.rb +143 -0
  204. data/lib/insika/settings_store.rb +154 -0
  205. data/lib/insika/shutdown.rb +125 -0
  206. data/lib/insika/skill_catalog.rb +220 -0
  207. data/lib/insika/skill_store.rb +127 -0
  208. data/lib/insika/steer_injector.rb +110 -0
  209. data/lib/insika/store.rb +52 -0
  210. data/lib/insika/stores/memory.rb +123 -0
  211. data/lib/insika/stores/sqlite.rb +183 -0
  212. data/lib/insika/studio/app.rb +1693 -0
  213. data/lib/insika/studio/assets/dist/application.css +1 -0
  214. data/lib/insika/studio/assets/dist/application.js +70 -0
  215. data/lib/insika/studio/forms.rb +335 -0
  216. data/lib/insika/studio/nav_icons.rb +31 -0
  217. data/lib/insika/studio/views/_message.erb +44 -0
  218. data/lib/insika/studio/views/agent_detail.erb +285 -0
  219. data/lib/insika/studio/views/agents.erb +63 -0
  220. data/lib/insika/studio/views/approvals.erb +41 -0
  221. data/lib/insika/studio/views/chats.erb +34 -0
  222. data/lib/insika/studio/views/evals.erb +83 -0
  223. data/lib/insika/studio/views/home.erb +72 -0
  224. data/lib/insika/studio/views/layout.erb +94 -0
  225. data/lib/insika/studio/views/login.erb +17 -0
  226. data/lib/insika/studio/views/mcp.erb +91 -0
  227. data/lib/insika/studio/views/not_found.erb +5 -0
  228. data/lib/insika/studio/views/playground.erb +47 -0
  229. data/lib/insika/studio/views/refinement.erb +234 -0
  230. data/lib/insika/studio/views/session.erb +137 -0
  231. data/lib/insika/studio/views/settings.erb +168 -0
  232. data/lib/insika/studio/views/skills.erb +141 -0
  233. data/lib/insika/studio/views/system_files.erb +65 -0
  234. data/lib/insika/studio/views/task.erb +105 -0
  235. data/lib/insika/studio/views/tasks.erb +33 -0
  236. data/lib/insika/studio/views/tool_edit.erb +107 -0
  237. data/lib/insika/studio/views/tools.erb +89 -0
  238. data/lib/insika/subagent_graph.rb +96 -0
  239. data/lib/insika/system_file_store.rb +96 -0
  240. data/lib/insika/task_actor.rb +128 -0
  241. data/lib/insika/task_store.rb +250 -0
  242. data/lib/insika/telemetry/pricing.rb +104 -0
  243. data/lib/insika/telemetry/recorder.rb +228 -0
  244. data/lib/insika/telemetry.rb +127 -0
  245. data/lib/insika/testing/store_contract.rb +270 -0
  246. data/lib/insika/tick.rb +122 -0
  247. data/lib/insika/token_estimator.rb +16 -0
  248. data/lib/insika/token_store.rb +168 -0
  249. data/lib/insika/tool_assembly.rb +140 -0
  250. data/lib/insika/tool_catalog.rb +89 -0
  251. data/lib/insika/tool_definition.rb +518 -0
  252. data/lib/insika/tool_envelope.rb +140 -0
  253. data/lib/insika/tool_manifest.rb +218 -0
  254. data/lib/insika/tool_output_compressor.rb +100 -0
  255. data/lib/insika/tool_registry.rb +21 -0
  256. data/lib/insika/tool_store.rb +135 -0
  257. data/lib/insika/tool_trace_store.rb +92 -0
  258. data/lib/insika/tools/a2a_remote.rb +48 -0
  259. data/lib/insika/tools/agent_enum.rb +68 -0
  260. data/lib/insika/tools/concurrency.rb +54 -0
  261. data/lib/insika/tools/data_defined_tool.rb +219 -0
  262. data/lib/insika/tools/load_skill.rb +99 -0
  263. data/lib/insika/tools/remember.rb +53 -0
  264. data/lib/insika/tools/stuck_signal.rb +44 -0
  265. data/lib/insika/tools/subagent.rb +75 -0
  266. data/lib/insika/tools/subagents.rb +77 -0
  267. data/lib/insika/tools/tool_search.rb +94 -0
  268. data/lib/insika/turn_output.rb +139 -0
  269. data/lib/insika/turn_state.rb +162 -0
  270. data/lib/insika/turn_timing.rb +56 -0
  271. data/lib/insika/usage_ledger.rb +47 -0
  272. data/lib/insika/version.rb +3 -1
  273. data/lib/insika/wiring/graph.rb +249 -0
  274. data/lib/insika/workflow.rb +185 -0
  275. data/lib/insika/workflow_registry.rb +33 -0
  276. data/lib/insika.rb +220 -4
  277. metadata +412 -8
@@ -0,0 +1,356 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "time"
5
+
6
+ module Insika
7
+ module Plugin
8
+ # Plugin loader. Runs EXCLUSIVELY at boot, single-fiber, before
9
+ # the server accepts connections — zero concurrency.
10
+ #
11
+ # Manifest: discovery without executing code; the entry require only happens
12
+ # after validation. A plugin failure doesn't bring down boot (rollback + warn).
13
+ class Loader
14
+ Discovered = Data.define(:id, :name, :root, :tool_names, :workflow_names,
15
+ :capability_names, :channel_names, :skill_dirs, :prompt_dirs, :config)
16
+
17
+ MANIFEST_GLOB = "{insika.plugin.yml,plugin.yml}"
18
+ SUPPORTED_CONTRACTS = %w[tools workflows capabilities channels].freeze
19
+
20
+ # registries: { tools:, workflows:, policies: } (Registry), hooks: (Hooks),
21
+ # middleware:/context_providers: (collections that respond to <<).
22
+ # announced_roots:/disabled: materialize per-root-class enablement;
23
+ # empty defaults keep the signature valid.
24
+ def initialize(roots:, registries:, enabled:, event_stream:,
25
+ announced_roots: [], disabled: [])
26
+ @roots = Array(roots)
27
+ @registries = registries
28
+ @enabled = Array(enabled).map(&:to_s)
29
+ @event_stream = event_stream
30
+ @announced_roots = Array(announced_roots).map { |r| File.expand_path(r.to_s) }
31
+ @disabled = Array(disabled).map(&:to_s)
32
+ end
33
+
34
+ # -> { skill_dirs: [], prompt_dirs: [], plugins: [Discovered] }
35
+ def load_all
36
+ seen = {}
37
+ skill_dirs = []
38
+ prompt_dirs = []
39
+ plugins = []
40
+
41
+ manifest_files.each do |file|
42
+ manifest = load_manifest(file)
43
+ next if manifest.nil?
44
+
45
+ id = manifest["id"].to_s
46
+ next if id.empty? || !enabled?(id, File.dirname(file)) || seen.key?(id)
47
+
48
+ config = validate_config(manifest, id)
49
+ next if config == :skip
50
+
51
+ discovered = build_discovered(manifest, File.dirname(file), config)
52
+ next unless load_entry(manifest, discovered)
53
+
54
+ skill_dirs.concat(discovered.skill_dirs)
55
+ prompt_dirs.concat(discovered.prompt_dirs)
56
+ plugins << discovered
57
+ seen[id] = discovered
58
+ emit_loaded(discovered)
59
+ end
60
+
61
+ { skill_dirs: skill_dirs, prompt_dirs: prompt_dirs, plugins: plugins }
62
+ end
63
+
64
+ # Per-root-class enablement: an announced gem is
65
+ # default-enabled, disableable via disabled:; workspace and bundled require
66
+ # explicit enabled:. disabled: is an absolute veto (deny wins,
67
+ # like the allowlists) — an id in disabled won't load even if it's in enabled.
68
+ def enabled?(id, plugin_dir)
69
+ return false if @disabled.include?(id)
70
+
71
+ announced?(plugin_dir) ? true : @enabled.include?(id)
72
+ end
73
+
74
+ private
75
+
76
+ def announced?(plugin_dir)
77
+ dir = File.expand_path(plugin_dir)
78
+ @announced_roots.any? { |root| dir == root || dir.start_with?("#{root}#{File::SEPARATOR}") }
79
+ end
80
+
81
+ # One manifest per directory: insika.plugin.yml takes precedence over
82
+ # plugin.yml (same dir). Order preserved (root precedence — first wins via `seen`).
83
+ def manifest_files
84
+ selected = {}
85
+ @roots.each do |root|
86
+ Dir.glob(File.join(root, "**", MANIFEST_GLOB)).sort.each do |file|
87
+ dir = File.dirname(file)
88
+ selected[dir] = file if selected[dir].nil? || File.basename(file) == "insika.plugin.yml"
89
+ end
90
+ end
91
+ selected.values
92
+ end
93
+
94
+ def load_manifest(file)
95
+ manifest = YAML.safe_load(File.read(file, encoding: "UTF-8")) || {}
96
+ return nil unless manifest.is_a?(Hash)
97
+
98
+ if File.basename(file) == "plugin.yml"
99
+ warn "[plugin #{manifest['id']}] plugin.yml is deprecated — rename to insika.plugin.yml"
100
+ end
101
+ manifest
102
+ rescue StandardError => e
103
+ warn "[plugin] unreadable manifest #{file}: #{e.class}: #{e.message}"
104
+ nil
105
+ end
106
+
107
+ # -> config Hash (valid) | :skip (fail-closed per plugin).
108
+ def validate_config(manifest, id)
109
+ schema = manifest["config_schema"]
110
+ config = manifest["config"] || {}
111
+ return config if schema.nil?
112
+
113
+ errors = ConfigSchema.validate(schema, config)
114
+ return config if errors.empty?
115
+
116
+ warn "[plugin #{id}] invalid config:\n#{errors.map { |e| " - #{e}" }.join("\n")}"
117
+ :skip
118
+ end
119
+
120
+ def build_discovered(manifest, root, config)
121
+ Discovered.new(
122
+ id: manifest["id"].to_s, name: manifest["name"].to_s, root: root,
123
+ tool_names: Array(manifest.dig("contracts", "tools")).map(&:to_s),
124
+ workflow_names: Array(manifest.dig("contracts", "workflows")).map(&:to_s),
125
+ capability_names: Array(manifest.dig("contracts", "capabilities")).map(&:to_s),
126
+ channel_names: Array(manifest.dig("contracts", "channels")).map(&:to_s),
127
+ skill_dirs: Array(manifest["skills"]).map { |d| File.join(root, d) },
128
+ prompt_dirs: Array(manifest["prompts"]).map { |d| File.join(root, d) },
129
+ config: config
130
+ )
131
+ end
132
+
133
+ # require + const_get + register(api) + commit of the staging. On failure ->
134
+ # warn+backtrace, rollback of the partial entries, plugin discarded,
135
+ # boot continues. Without an entry: only skills/prompts (no registration).
136
+ def load_entry(manifest, discovered)
137
+ entry = manifest["entry"]
138
+ return true if entry.nil?
139
+
140
+ api = RegistrationAPI.new(
141
+ registries: @registries, plugin_id: discovered.id,
142
+ tool_names: discovered.tool_names, workflow_names: discovered.workflow_names,
143
+ capability_names: discovered.capability_names, channel_names: discovered.channel_names,
144
+ tool_metadata: manifest["tool_metadata"] || {}, config: discovered.config
145
+ )
146
+ require File.join(discovered.root, entry)
147
+ Object.const_get(manifest.fetch("module")).register(api)
148
+ api.commit!
149
+ true
150
+ rescue StandardError => e
151
+ warn "[plugin #{discovered.id}] failed to load: #{e.class}: #{e.message}\n" \
152
+ "#{Array(e.backtrace).first(5).join("\n")}"
153
+ rollback(discovered.id)
154
+ false
155
+ end
156
+
157
+ def rollback(id)
158
+ %i[tools workflows policies capabilities channels].each { |kind| @registries[kind]&.deregister_plugin(id) }
159
+ end
160
+
161
+ def emit_loaded(discovered)
162
+ @event_stream.emit(Insika::Event.new(
163
+ type: :plugin_loaded,
164
+ data: { id: discovered.id, tools: discovered.tool_names,
165
+ skills: discovered.skill_dirs },
166
+ meta: { at: Time.now.utc.iso8601 }
167
+ ))
168
+ end
169
+
170
+ # Facade passed to the plugin. A contract is required only for tools and
171
+ # workflows (addressable by name); middleware/hooks/providers are
172
+ # STAGED and made effective by commit! only when register(api) returns
173
+ # without an exception (materializes the rollback guarantee — nothing partial remains).
174
+ class RegistrationAPI
175
+ def initialize(registries:, plugin_id:, tool_names:, workflow_names:,
176
+ tool_metadata:, config:, capability_names: [], channel_names: [])
177
+ @registries = registries
178
+ @plugin_id = plugin_id
179
+ @tool_names = tool_names
180
+ @workflow_names = workflow_names
181
+ @capability_names = capability_names
182
+ @channel_names = channel_names
183
+ @tool_metadata = tool_metadata
184
+ @config = config.freeze
185
+ @staged_middleware = []
186
+ @staged_providers = []
187
+ @staged_hooks = []
188
+ @staged_capabilities = []
189
+ end
190
+
191
+ def register_tool(name, klass = nil, &block)
192
+ name = name.to_s
193
+ unless @tool_names.include?(name)
194
+ warn "[plugin #{@plugin_id}] tool '#{name}' not declared in contracts.tools — ignored"
195
+ return
196
+ end
197
+ meta = @tool_metadata[name] || {}
198
+ @registries[:tools].register(name, klass, plugin: @plugin_id,
199
+ optional: !!meta["optional"],
200
+ side_effect: !!meta["side_effect"], &block)
201
+ end
202
+
203
+ def register_workflow(name, callable = nil, &block)
204
+ name = name.to_s
205
+ unless @workflow_names.include?(name)
206
+ warn "[plugin #{@plugin_id}] workflow '#{name}' not declared in contracts.workflows — ignored"
207
+ return
208
+ end
209
+ @registries[:workflows].register(name, callable, plugin: @plugin_id, &block)
210
+ end
211
+
212
+ # a channel is an INSTANCE (it holds its credentials and its
213
+ # HTTP client), so unlike a tool there is no factory to defer. Declared-or-
214
+ # ignored like the others: the id is a URL segment, and a plugin quietly
215
+ # mounting a route nobody declared is exactly what the contract list prevents.
216
+ def register_channel(name, instance)
217
+ name = name.to_s
218
+ unless @channel_names.include?(name)
219
+ warn "[plugin #{@plugin_id}] channel '#{name}' not declared in contracts.channels — ignored"
220
+ return
221
+ end
222
+
223
+ @registries[:channels]&.register(name, instance, plugin: @plugin_id)
224
+ end
225
+
226
+ def register_policy(name, klass)
227
+ @registries[:policies].register(name, klass, plugin: @plugin_id)
228
+ end
229
+
230
+ # Mirrors register_tool ("not declared -> warn + ignore"), + tool:/workflow:
231
+ # exclusivity and the warn for kind :workflow (no consumer in this slice).
232
+ # Staged; made effective at commit! (nothing partial if register(api) raises).
233
+ def register_capability(name, tool: nil, workflow: nil, priority: nil, available: nil)
234
+ name = name.to_s
235
+ unless @capability_names.include?(name)
236
+ warn "[plugin #{@plugin_id}] capability '#{name}' not declared in contracts.capabilities — ignored"
237
+ return
238
+ end
239
+
240
+ if tool && workflow
241
+ warn "[plugin #{@plugin_id}] capability '#{name}': provide only tool: OR workflow:, not both — ignored"
242
+ return
243
+ end
244
+
245
+ impl_name, kind =
246
+ if tool then [tool.to_s, :tool]
247
+ elsif workflow then [workflow.to_s, :workflow]
248
+ end
249
+
250
+ if impl_name.nil?
251
+ warn "[plugin #{@plugin_id}] capability '#{name}': informe tool: ou workflow: — ignorada"
252
+ return
253
+ end
254
+
255
+ if kind == :workflow
256
+ warn "[plugin #{@plugin_id}] capability '#{name}' (kind: workflow) registered without a consumer " \
257
+ "in this slice — agent exposure is follow-up"
258
+ end
259
+
260
+ @staged_capabilities << { capability: name, impl_name: impl_name, kind: kind,
261
+ priority: priority, available: available }
262
+ end
263
+
264
+ def register_middleware(instance) = @staged_middleware << instance
265
+ def register_context_provider(instance) = @staged_providers << instance
266
+
267
+ # Validates the pair AT stage time (not only at commit): this way an invalid
268
+ # pair raises INSIDE register(api) -> the staging is discarded and the rollback
269
+ # covers everything. If validation happened only at commit!, middleware/providers
270
+ # would already have been made effective when the bad hook raised.
271
+ def register_hook(pair, before: nil, after: nil)
272
+ unless Insika::Hooks::PAIRS.include?(pair)
273
+ raise ArgumentError, "unknown hook pair: #{pair.inspect}"
274
+ end
275
+
276
+ @staged_hooks << [pair, before, after]
277
+ end
278
+
279
+ def config = @config
280
+
281
+ # Atomic by construction: the hook pairs were already validated at stage
282
+ # time, so no line here raises (array <<, hooks.register of a valid pair).
283
+ def commit!
284
+ @staged_middleware.each { |m| @registries[:middleware] << m }
285
+ @staged_providers.each { |p| @registries[:context_providers] << p }
286
+ @staged_hooks.each { |pair, before, after| @registries[:hooks].register(pair, before: before, after: after) }
287
+ @staged_capabilities.each do |c|
288
+ @registries[:capabilities].register(c[:capability], impl_name: c[:impl_name], kind: c[:kind],
289
+ plugin: @plugin_id, priority: c[:priority],
290
+ available: c[:available])
291
+ end
292
+ end
293
+ end
294
+
295
+ # Subset JSON Schema validator (no gem; swappable).
296
+ # validate(schema, value) -> [String] (empty = valid). An invalid schema AND
297
+ # a config that fails validation land in the same list (fail-closed per plugin).
298
+ module ConfigSchema
299
+ KEYWORDS = %w[type properties required additionalProperties enum].freeze
300
+ TYPES = {
301
+ "object" => [Hash], "array" => [Array], "string" => [String],
302
+ "integer" => [Integer], "number" => [Numeric],
303
+ "boolean" => [TrueClass, FalseClass], "null" => [NilClass]
304
+ }.freeze
305
+
306
+ def self.validate(schema, value, path = "config")
307
+ return ["#{path}: schema must be a Hash"] unless schema.is_a?(Hash)
308
+
309
+ errors = []
310
+ unknown = schema.keys - KEYWORDS
311
+ errors << "#{path}: unsupported keyword(s): #{unknown.join(', ')}" unless unknown.empty?
312
+ errors.concat(check_type(schema, value, path))
313
+ errors.concat(check_enum(schema, value, path))
314
+ errors.concat(check_object(schema, value, path))
315
+ errors
316
+ end
317
+
318
+ def self.check_type(schema, value, path)
319
+ return [] unless schema.key?("type")
320
+
321
+ klasses = TYPES[schema["type"]]
322
+ return ["#{path}: unknown type: #{schema['type'].inspect}"] if klasses.nil?
323
+ return [] if klasses.any? { |k| value.is_a?(k) }
324
+
325
+ ["#{path}: expected #{schema['type']}, got #{value.class}"]
326
+ end
327
+
328
+ def self.check_enum(schema, value, path)
329
+ return [] unless schema.key?("enum")
330
+ return [] if Array(schema["enum"]).include?(value)
331
+
332
+ ["#{path}: value #{value.inspect} not in enum"]
333
+ end
334
+
335
+ def self.check_object(schema, value, path)
336
+ return [] unless schema.key?("properties") || schema.key?("required") ||
337
+ schema.key?("additionalProperties")
338
+
339
+ props = schema["properties"] || {}
340
+ return ["#{path}: properties must be a Hash"] unless props.is_a?(Hash)
341
+ return [] unless value.is_a?(Hash) # object keywords only apply to a Hash
342
+
343
+ errors = []
344
+ props.each { |k, sub| errors.concat(validate(sub, value[k], "#{path}.#{k}")) if value.key?(k) }
345
+ Array(schema["required"]).each do |req|
346
+ errors << "#{path}: missing required key: #{req}" unless value.key?(req)
347
+ end
348
+ if schema["additionalProperties"] == false && !(extra = value.keys - props.keys).empty?
349
+ errors << "#{path}: keys not allowed: #{extra.join(', ')}"
350
+ end
351
+ errors
352
+ end
353
+ end
354
+ end
355
+ end
356
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Per-gem boot hook (Railtie style): the plugin gem
5
+ # calls Insika::Plugin.announce(root) when loading its lib/ (before boot); the
6
+ # composition root consumes announced_roots when building the Loader. Explicit
7
+ # and cheap — NO scanning of LOAD_PATH/installed gems.
8
+ #
9
+ # This file is MINIMAL and dependency-free: third-party gems can load it
10
+ # before anything else from Insika. The Loader lives in plugin/loader.rb
11
+ # (same module, reopened) — this file does NOT require it.
12
+ module Plugin
13
+ @announced_roots = []
14
+
15
+ class << self
16
+ # Accumulates roots BEFORE boot, in the gems' require ORDER (that's what
17
+ # defines precedence among gems). Dedupes by expanded path.
18
+ def announce(root)
19
+ root = File.expand_path(root.to_s)
20
+ @announced_roots << root unless @announced_roots.include?(root)
21
+ root
22
+ end
23
+
24
+ # Frozen copy — nobody mutates the accumulator from outside.
25
+ def announced_roots
26
+ @announced_roots.dup.freeze
27
+ end
28
+
29
+ # TEST support (the accumulator is process state). Do not use in production.
30
+ def reset_announced!
31
+ @announced_roots = []
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Insika
6
+ module Policy
7
+ # Pipeline stage 3. Aggregates the profile's policies in the
8
+ # declared order: intersection of non-nil allows, union of denies
9
+ # (commutative, ties impossible by construction). Fail-closed: a crash or
10
+ # an unregistered name becomes a deny. Runs INLINE on the fiber (pure, no IO — no
11
+ # Async, no timeout of its own).
12
+ class Engine
13
+ Resolution = Data.define(:allowed_tools, :allowed_skills, :requires_approval, :audit)
14
+
15
+ def initialize(policy_registry:, event_stream:)
16
+ @registry = policy_registry # duck-type: fetch(name)
17
+ @event_stream = event_stream
18
+ end
19
+
20
+ # -> Resolution | raise PolicyDenied
21
+ def decide(request)
22
+ audit = []
23
+ allow_tool_sets = []
24
+ deny_tools = []
25
+ allow_skill_sets = []
26
+ deny_skills = []
27
+ requires_approval = []
28
+
29
+ Array(request.profile.policies).each do |name|
30
+ decision = evaluate(name, request)
31
+ audit << { policy: name.to_s, verdict: decision.verdict, reason: decision.reason }
32
+
33
+ deny_turn!(name, decision.reason, audit) if decision.verdict == :deny
34
+
35
+ allow_tool_sets << decision.allow_tools.map(&:to_s) unless decision.allow_tools.nil?
36
+ deny_tools.concat(Array(decision.deny_tools).map(&:to_s))
37
+ allow_skill_sets << decision.allow_skills.map(&:to_s) unless decision.allow_skills.nil?
38
+ deny_skills.concat(Array(decision.deny_skills).map(&:to_s))
39
+ requires_approval.concat(Array(decision.requires_approval).map(&:to_s))
40
+ end
41
+
42
+ Resolution.new(
43
+ allowed_tools: filter(request.candidate_tools, allow_tool_sets, deny_tools) { |e| e.name.to_s },
44
+ allowed_skills: filter(request.candidate_skills, allow_skill_sets, deny_skills) { |s| s.name.to_s },
45
+ requires_approval: requires_approval.uniq,
46
+ audit: audit
47
+ )
48
+ end
49
+
50
+ private
51
+
52
+ # Fail-closed: any exception (policy bug OR unregistered name)
53
+ # becomes a deny — NEVER fail-open.
54
+ def evaluate(name, request)
55
+ policy = @registry.fetch(name)
56
+ raise "policy not registered: #{name}" if policy.nil?
57
+
58
+ policy.decide(request)
59
+ rescue StandardError => e
60
+ Decision.deny(reason: "policy crash: #{e.class}")
61
+ end
62
+
63
+ # The first deny reports (event + PolicyDenied) and halts: the following
64
+ # policies don't even run, but the audit records up to here.
65
+ def deny_turn!(policy_name, reason, _audit)
66
+ @event_stream.emit(Insika::Event.new(
67
+ type: :policy_denied,
68
+ data: { policy: policy_name.to_s, reason: reason },
69
+ meta: { at: Time.now.utc.iso8601 }
70
+ ))
71
+ raise Insika::PolicyDenied.new(policy: policy_name.to_s, reason: reason)
72
+ end
73
+
74
+ # allowed_names = names(candidates) ∩ (∩ non-nil allows) − (∪ denies).
75
+ def filter(candidates, allow_sets, deny_names)
76
+ allowed = candidates.map { |c| yield(c) }
77
+ allow_sets.each { |set| allowed &= set }
78
+ allowed -= deny_names
79
+ candidates.select { |c| allowed.include?(yield(c)) }
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Policy
5
+ # Stage 3 input. candidate_tools = [ToolRegistry::Entry]
6
+ # (UNfiltered); candidate_skills = [SkillCatalog::Skill] from
7
+ # catalog.effective(profile.skills). context = ContextPackage (Context before
8
+ # Policy).
9
+ PolicyRequest = Data.define(:profile, :command, :context,
10
+ :candidate_tools, :candidate_skills)
11
+
12
+ # Output of ONE policy. allow_* == nil => "no restriction from this
13
+ # policy" (does not enter the intersection); [] => empty set; deny_* is always
14
+ # a list (union). verdict :deny denies the WHOLE TURN. All immutable Data — the
15
+ # policy's purity is structural.
16
+ # `requires_approval`: names of tools that require human approval —
17
+ # does NOT deny or allow; the real gate is in the ToolEnvelope (stage 6). Default [].
18
+ Decision = Data.define(:allow_tools, :deny_tools, :allow_skills, :deny_skills,
19
+ :requires_approval, :verdict, :reason) do
20
+ def self.allow(allow_tools: nil, deny_tools: [], allow_skills: nil, deny_skills: [],
21
+ requires_approval: [])
22
+ new(allow_tools: allow_tools, deny_tools: deny_tools, allow_skills: allow_skills,
23
+ deny_skills: deny_skills, requires_approval: requires_approval,
24
+ verdict: :allow, reason: nil)
25
+ end
26
+
27
+ def self.deny(reason:, allow_tools: nil, deny_tools: [], allow_skills: nil, deny_skills: [],
28
+ requires_approval: [])
29
+ new(allow_tools: allow_tools, deny_tools: deny_tools, allow_skills: allow_skills,
30
+ deny_skills: deny_skills, requires_approval: requires_approval,
31
+ verdict: :deny, reason: reason)
32
+ end
33
+ end
34
+
35
+ # Policy base class. PURE — no IO, no mutation; determinism
36
+ # is required by the handoff.
37
+ class Base
38
+ def id = self.class.name
39
+
40
+ def decide(_request)
41
+ raise NotImplementedError, "#{self.class}#decide"
42
+ end
43
+ end
44
+
45
+ # Builtin policies.
46
+ module Builtin
47
+ # Absorbs ToolRegistry#resolve as a policy: optional without
48
+ # opt-in -> deny; tools_deny -> deny ("deny always wins"); tools_allow
49
+ # with the semantics (nil = all; [] = ∅; [names] = final set).
50
+ # `tools_allow_groups` UNIONS in the groups' tools
51
+ # (the group is GIVEN in the Entry metadata). Both allowlists nil = all.
52
+ class ToolAllowlist < Base
53
+ def decide(request)
54
+ profile = request.profile
55
+ # optional lives in the Entry metadata; candidate_tools are
56
+ # ToolRegistry::Entry (Registry::Entry with metadata).
57
+ deny = request.candidate_tools
58
+ .select { |e| e.metadata[:optional] && !profile.tool_opted_in?(e.name) }
59
+ .map { |e| e.name.to_s }
60
+ deny += Array(profile.tools_deny).map(&:to_s)
61
+
62
+ allow = allowed_names(profile, request.candidate_tools)
63
+ Decision.allow(allow_tools: allow, deny_tools: deny.uniq)
64
+ end
65
+
66
+ private
67
+
68
+ # nil = NO restriction (all) ONLY when tools_allow AND tools_allow_groups
69
+ # are absent (parity). Otherwise it's the UNION: explicit names +
70
+ # tools whose `group` (metadata) is in tools_allow_groups. An allow []
71
+ # (with groups nil) still = ∅.
72
+ def allowed_names(profile, candidates)
73
+ names = profile.tools_allow.nil? ? nil : Array(profile.tools_allow).map(&:to_s)
74
+ groups = profile.tools_allow_groups.nil? ? nil : Array(profile.tools_allow_groups).map(&:to_s)
75
+ return nil if names.nil? && groups.nil?
76
+
77
+ from_groups = Array(groups).empty? ? [] : candidates
78
+ .select { |e| groups.include?(e.metadata[:group].to_s) }
79
+ .map { |e| e.name.to_s }
80
+ Array(names) | from_groups
81
+ end
82
+ end
83
+
84
+ # nil/[]/[names] semantics of profile.skills. effective
85
+ # stays in the catalog (query); the DECISION to use it is here.
86
+ class SkillAllowlist < Base
87
+ def decide(request)
88
+ allow = request.profile.skills.nil? ? nil : Array(request.profile.skills).map(&:to_s)
89
+ Decision.allow(allow_skills: allow)
90
+ end
91
+ end
92
+
93
+ # Enforcement of the workflows_allow field. Neutral outside
94
+ # :trigger_workflow; denies the turn when the workflow is not in the allowlist.
95
+ class WorkflowAllowlist < Base
96
+ def decide(request)
97
+ command = request.command
98
+ return Decision.allow if command.nil? || command.type != :trigger_workflow
99
+
100
+ name = (command.payload[:workflow] || command.payload["workflow"]).to_s
101
+ allow = request.profile.workflows_allow
102
+ return Decision.allow if allow.nil? || Array(allow).map(&:to_s).include?(name)
103
+
104
+ Decision.deny(reason: "workflow '#{name}' not in the allowlist of agent '#{request.profile.id}'")
105
+ end
106
+ end
107
+
108
+ # Marks tools that require human approval. Does not deny/allow —
109
+ # attaches `requires_approval` to the Resolution; the gate is in the ToolEnvelope
110
+ # (stage 6), where the call happens. Allowlist semantics: nil = none; [names] =
111
+ # those require approval. Pure/synchronous.
112
+ class ApprovalRequired < Base
113
+ def decide(request)
114
+ names = request.profile.approvals_required
115
+ Decision.allow(requires_approval: names.nil? ? [] : Array(names).map(&:to_s))
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Policy registry. `resolve(name) -> Policy::Base`.
5
+ # The builtins (ToolAllowlist/SkillAllowlist/WorkflowAllowlist) are registered
6
+ # AT BOOT by the composition root (config/wiring.rb), not here.
7
+ #
8
+ # NB: the Policy::Engine consumes via `fetch(name)`; a Hash also
9
+ # satisfies that duck-type. This registry is the production implementation — to
10
+ # use it with the Engine, expose `fetch` delegating to `resolve`.
11
+ class PolicyRegistry < Registry
12
+ # -> Policy::Base (INSTANCE): the Engine calls #decide on the result. If the
13
+ # policy was registered as a CLASS, instantiate it; if it already came as an
14
+ # instance/factory returning an instance, pass it through.
15
+ def resolve(name)
16
+ resolved = super
17
+ resolved.is_a?(Class) ? resolved.new : resolved
18
+ end
19
+
20
+ # Alias for the duck-type the Policy::Engine expects (fetch(name) -> Policy::Base).
21
+ def fetch(name) = resolve(name)
22
+ end
23
+ end