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,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Safety
5
+ # Safe reply for a blocked turn. A blocked turn NEVER returns a
6
+ # raw error nor silence — it completes gracefully with one of these.
7
+ #
8
+ # CONFIGURATION OVER CONVENTION: this is OSS across arbitrary businesses and
9
+ # languages, so the engine does NOT hard-bake tone. The built-in `DEFAULTS` are a
10
+ # deliberately NEUTRAL, business-agnostic fallback (pt-BR — the pilot's language;
11
+ # override for others). Each agent tailors its own voice via
12
+ # `guardrails.responses` (Safety::Config#responses). Resolution order:
13
+ #
14
+ # agent[category] → agent["default"] → DEFAULTS[category] → DEFAULTS[:default]
15
+ #
16
+ # So a store that wants a warm brand voice, a different language, or a specific
17
+ # discount-scam reply just configures it — nothing here is a ceiling.
18
+ module SafeResponses
19
+ # Neutral, generic fallback. No brand, no vertical ("loja"/"produtos") baked in
20
+ # beyond what a generic assistant can say. Agents are expected to override.
21
+ DEFAULTS = {
22
+ injection: "Não consigo compartilhar minhas instruções internas ou " \
23
+ "configurações. Como posso te ajudar de outra forma?",
24
+ sexual: "Prefiro manter nossa conversa respeitosa e profissional. " \
25
+ "Como posso te ajudar?",
26
+ abuse: "Sinto muito pela experiência. Quero te ajudar — me conta o que " \
27
+ "você precisa e eu sigo daqui, ou te encaminho para um atendente humano.",
28
+ escalate: "Vou te encaminhar para um atendente humano que poderá te ajudar " \
29
+ "melhor com isso. Um momento, por favor.",
30
+ default: "Não consigo ajudar com esse pedido específico, mas estou à " \
31
+ "disposição para o que mais você precisar."
32
+ }.freeze
33
+
34
+ module_function
35
+
36
+ # Safe reply for a category, honoring the agent's per-category / catch-all
37
+ # overrides first. `overrides` is Safety::Config#responses ({ "cat" => text }).
38
+ # Always returns a non-blank string.
39
+ def for(category, overrides: {})
40
+ cat = category&.to_s
41
+ ov = overrides || {}
42
+ ov[cat] || ov["default"] ||
43
+ DEFAULTS[cat&.to_sym] || DEFAULTS[:default]
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Insika
6
+ module Sandbox
7
+ # HARD filesystem boundary: confines every path a tool resolves to a single
8
+ # root directory. Each model/user supplied path is resolved and verified to
9
+ # live INSIDE the root BEFORE any IO happens. This is the FS half of the
10
+ # sandbox primitive — always on, independent of the exec provider (local or
11
+ # docker) and of the engine's approval layer.
12
+ #
13
+ # Extracted VERBATIM from the insika-code prototype's `Workspace`
14
+ # and promoted to a core primitive. The two layers of defense:
15
+ # 1. `File.expand_path` normalizes `..` traversal; the expanded path must be
16
+ # the root itself or a descendant of it (string containment on a
17
+ # separator boundary, so `/ws-evil` does not pass for root `/ws`).
18
+ # 2. symlink guard: the final path component may never be a symlink (an
19
+ # lstat check that also catches a BROKEN symlink whose target does not
20
+ # yet exist), and for an existing target (or, on writes, its parent dir)
21
+ # the REAL path (`File.realpath`, which follows symlinks) must also be
22
+ # contained — a symlink inside the sandbox pointing outside is rejected.
23
+ #
24
+ # A value object: immutable root, no IO of its own beyond the realpath checks.
25
+ class Boundary
26
+ # Raised on any attempt to touch a path outside the root. Tools rescue it
27
+ # and return a structured error to the model (never crash a turn).
28
+ Escape = Class.new(StandardError)
29
+
30
+ attr_reader :root
31
+
32
+ # root: the directory that bounds all operations. Must exist (a boundary
33
+ # rooted at a missing dir is a misconfiguration -> fail fast at boot).
34
+ def initialize(root)
35
+ @root = File.realpath(File.expand_path(root.to_s))
36
+ rescue Errno::ENOENT
37
+ raise Escape, "sandbox root does not exist: #{root}"
38
+ end
39
+
40
+ # Resolve a relative/absolute path to an absolute path GUARANTEED inside the
41
+ # root. Raises Escape on traversal/symlink escape or an empty path.
42
+ # for_write: the target file may not exist yet, so the symlink guard is
43
+ # applied to its PARENT directory instead of the file itself.
44
+ def resolve(path, for_write: false)
45
+ raise Escape, "empty path" if path.to_s.strip.empty?
46
+
47
+ abs = File.expand_path(path.to_s, @root)
48
+ contain!(abs)
49
+
50
+ # The final component is never allowed to be a symlink. On writes this is
51
+ # the crux of the boundary: `contain!` only checks the STRING, and the
52
+ # parent-dir realpath probe below only vets the parent — so without this a
53
+ # symlink under the root pointing outside would let `File.write` follow it
54
+ # and clobber a file outside the sandbox. `File.symlink?` is an lstat, so
55
+ # it also catches a BROKEN symlink (dangling target) that `File.exist?`
56
+ # would report as absent.
57
+ raise Escape, "path is a symlink" if File.symlink?(abs)
58
+
59
+ probe = for_write ? File.dirname(abs) : abs
60
+ contain!(File.realpath(probe)) if File.exist?(probe)
61
+ abs
62
+ end
63
+
64
+ # Boolean containment check for an ALREADY-absolute path (used by grep to
65
+ # skip glob results that resolve outside the root via a symlink). Never
66
+ # raises.
67
+ def inside?(abs)
68
+ real = File.exist?(abs) ? File.realpath(abs) : File.expand_path(abs)
69
+ real == @root || real.start_with?(@root + File::SEPARATOR)
70
+ rescue StandardError
71
+ false
72
+ end
73
+
74
+ # Path relative to the root, for display — never leaks absolute host paths
75
+ # back to the model. The root itself renders as ".".
76
+ def relative(abs)
77
+ return "." if abs == @root
78
+
79
+ abs.delete_prefix(@root + File::SEPARATOR)
80
+ end
81
+
82
+ private
83
+
84
+ def contain!(abs)
85
+ return if abs == @root || abs.start_with?(@root + File::SEPARATOR)
86
+
87
+ raise Escape, "path escapes sandbox root (#{relative_or_abs(abs)})"
88
+ end
89
+
90
+ def relative_or_abs(abs) = abs.start_with?(@root) ? relative(abs) : abs
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require_relative "runner"
5
+
6
+ module Insika
7
+ module Sandbox
8
+ # Isolated exec provider: runs the command inside a throwaway Docker container
9
+ # (`docker run --rm`) with the sandbox root bind-mounted at a fixed workdir.
10
+ # This is the isolation boundary the `local` provider is NOT — a real target
11
+ # for UNTRUSTED shell execution.
12
+ #
13
+ # "Narrowest sandbox that supports the task": only the risky part — shell exec
14
+ # — is containerized. The FS tools (read/write/edit/grep) keep operating on
15
+ # the host path, confined by the `Boundary`; the container sees the SAME bytes
16
+ # through the bind mount, so a file the model writes is immediately visible to
17
+ # a subsequent `bash` call and vice-versa.
18
+ #
19
+ # Defaults are conservative: `--network none` (no egress from the container),
20
+ # a memory cap and a cpu cap, and a minimal image. All are overridable via the
21
+ # profile's `sandbox` config (config-over-code).
22
+ class Docker
23
+ DEFAULTS = {
24
+ "image" => "alpine:3.20",
25
+ "network" => "none",
26
+ "memory" => "512m",
27
+ "cpus" => "1.0",
28
+ "workdir" => "/workspace",
29
+ "shell" => "/bin/sh",
30
+ "docker_bin" => "docker"
31
+ }.freeze
32
+
33
+ def initialize(config = {})
34
+ cfg = DEFAULTS.merge(config.transform_keys(&:to_s).compact)
35
+ @image = cfg["image"]
36
+ @network = cfg["network"]
37
+ @memory = cfg["memory"]
38
+ @cpus = cfg["cpus"].to_s
39
+ @workdir = cfg["workdir"]
40
+ @shell = cfg["shell"]
41
+ @docker_bin = cfg["docker_bin"]
42
+ end
43
+
44
+ # PURE argv builder — unit-testable without Docker present. `--name` lets
45
+ # the timeout teardown `docker kill` the exact container (killing the client
46
+ # process alone does not stop it).
47
+ def argv(command, root:, name:)
48
+ [@docker_bin, "run", "--rm", "--name", name,
49
+ "--network", @network, "--memory", @memory, "--cpus", @cpus,
50
+ "--volume", "#{root}:#{@workdir}:rw", "--workdir", @workdir,
51
+ @image, @shell, "-c", command.to_s]
52
+ end
53
+
54
+ def exec(command, root:, timeout:, max_output:)
55
+ name = "harness-sbx-#{SecureRandom.hex(8)}"
56
+ Runner.run(argv(command, root: root, name: name),
57
+ chdir: root, timeout: timeout, max_output: max_output,
58
+ # On the wall-clock deadline, stop the container by name; the
59
+ # `--rm` then removes it. Squelch output — this is teardown.
60
+ kill: -> { system(@docker_bin, "kill", name, out: File::NULL, err: File::NULL) })
61
+ end
62
+
63
+ # Whether the Docker daemon is reachable. Used at boot to fail fast (or warn)
64
+ # instead of discovering it mid-turn.
65
+ def available?
66
+ system(@docker_bin, "version", out: File::NULL, err: File::NULL)
67
+ rescue StandardError
68
+ false
69
+ end
70
+
71
+ def to_s = "docker(#{@image}, network=#{@network})"
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "runner"
4
+
5
+ module Insika
6
+ module Sandbox
7
+ # Default exec provider: runs the command IN-PROCESS on the host, with the
8
+ # working directory pinned to the sandbox root. This is the "narrowest sandbox
9
+ # that supports the task" for trusted/local operation — cheap, no daemon, no
10
+ # image pull. It is NOT an isolation boundary for a shell (a command can still
11
+ # read absolute paths or `cd ..`); that is why shell tools stay approval-gated
12
+ # and why the `docker` provider exists for untrusted execution.
13
+ #
14
+ # The improvement over the prototype's raw `capture2e` is the real hard-kill
15
+ # timeout (via Runner): a hung command no longer holds the turn open until the
16
+ # OS returns.
17
+ class Local
18
+ # `-c` (non-login): a login shell would source ~/.bash_profile on every
19
+ # call, adding latency and letting host dotfiles mutate PATH/env under the
20
+ # command — surprising for a sandboxed tool.
21
+ def initialize(shell: "/bin/bash")
22
+ @shell = shell
23
+ end
24
+
25
+ def exec(command, root:, timeout:, max_output:)
26
+ Runner.run([@shell, "-c", command.to_s],
27
+ chdir: root, timeout: timeout, max_output: max_output)
28
+ end
29
+
30
+ def to_s = "local(#{@shell})"
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require_relative "../coercion"
5
+
6
+ module Insika
7
+ module Sandbox
8
+ # Structured result of a sandboxed command. `timed_out` distinguishes a
9
+ # deadline kill (exit_status nil) from a normal exit. `output` is the combined
10
+ # stdout+stderr, already clipped to the provider's cap.
11
+ Result = Data.define(:exit_status, :output, :timed_out) do
12
+ def timed_out? = timed_out
13
+ # Shape returned to the model by the bash tool (parity with the prototype).
14
+ def to_h = { exit_status: exit_status, output: output, timed_out: timed_out }
15
+ end
16
+
17
+ # Spawns a command with a REAL hard-kill timeout, shared by every exec
18
+ # provider. Unlike the prototype's `Open3.capture2e` (which blocks
19
+ # uninterruptibly — a hung command holds the fiber until the OS returns), this
20
+ # bounds wall-clock: on expiry the process (and, via its own process group,
21
+ # any children) is force-killed and whatever partial output was captured is
22
+ # returned with `timed_out: true`.
23
+ #
24
+ # Stdlib `Timeout.timeout` is deliberately NOT used (forbidden by the engine's
25
+ # fiber contract, see errors.rb); the deadline is enforced by
26
+ # `Thread#join(timeout)` — a single bounded blocking call — and the reader
27
+ # runs on its own thread so partial output survives the kill.
28
+ module Runner
29
+ # Grace period for the output reader to drain after the process is killed.
30
+ DRAIN_TIMEOUT = 2
31
+
32
+ module_function
33
+
34
+ # argv: the command as an argv array (never a shell string — no
35
+ # re-interpretation by the host shell).
36
+ # chdir: working directory of the spawned process.
37
+ # kill: optional extra teardown (e.g. `docker kill <name>`); the
38
+ # process group is ALWAYS killed regardless.
39
+ def run(argv, chdir:, timeout:, max_output:, env: {}, kill: nil)
40
+ # pgroup: true -> the child is a new group leader (pgid == pid), so a
41
+ # single kill on the negated pid reaps the command AND anything it forked.
42
+ Open3.popen2e(env, *argv, chdir: chdir, pgroup: true) do |stdin, out, wait_thr|
43
+ stdin.close
44
+ reader = Thread.new { out.read }
45
+
46
+ if wait_thr.join(timeout).nil?
47
+ terminate(wait_thr.pid, kill)
48
+ Result.new(exit_status: nil, output: drain(reader, max_output), timed_out: true)
49
+ else
50
+ Result.new(exit_status: wait_thr.value.exitstatus,
51
+ output: drain(reader, max_output), timed_out: false)
52
+ end
53
+ end
54
+ end
55
+
56
+ # Kill the whole process group, then run any provider-specific teardown.
57
+ # ESRCH (already gone) / EPERM are benign races — the process is dying.
58
+ def terminate(pid, kill)
59
+ Process.kill("KILL", -pid)
60
+ rescue Errno::ESRCH, Errno::EPERM
61
+ nil
62
+ ensure
63
+ kill&.call
64
+ end
65
+
66
+ # Collect the reader's output within a grace window. After a kill the write
67
+ # end closes and `out.read` returns promptly; the bound guards a reader that
68
+ # somehow does not (returns "" rather than blocking the fiber forever).
69
+ #
70
+ # The output of an arbitrary command is arbitrary bytes: scrubbed to valid
71
+ # UTF-8 before clipping (the clip is then by character, never splitting one)
72
+ # because it becomes a tool result and gets JSON-serialized downstream.
73
+ def drain(reader, max_output)
74
+ return "" if reader.join(DRAIN_TIMEOUT).nil?
75
+
76
+ Insika::Coercion.utf8(reader.value)[0, max_output]
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "coercion"
4
+ require_relative "sandbox/boundary"
5
+ require_relative "sandbox/runner"
6
+ require_relative "sandbox/local"
7
+ require_relative "sandbox/docker"
8
+
9
+ module Insika
10
+ # Sandbox primitive (COMPETITIVE-ANALYSIS): a single, pluggable
11
+ # interface for confined execution, promoted to the core from the insika-code
12
+ # prototype. Two halves:
13
+ #
14
+ # * FS confinement (`Boundary`) — ALWAYS host-side, always on: every path a
15
+ # tool resolves is proven inside the root before any IO.
16
+ # * command exec — via a swappable PROVIDER: `local` (in-process, the default)
17
+ # or `docker` (isolated container). Providers are chosen by DATA, not code
18
+ # (config-over-code): the agent profile's `sandbox` block names the provider
19
+ # and its policy.
20
+ #
21
+ # `Env` is the object a tool holds; `Sandbox.build(config)` assembles one from
22
+ # the declarative config (the same hash shape stored on the profile).
23
+ module Sandbox
24
+ # Ergonomic alias so tools can rescue `Insika::Sandbox::Escape` without
25
+ # reaching into the Boundary. It IS Boundary::Escape (same class).
26
+ Escape = Boundary::Escape
27
+
28
+ DEFAULT_TIMEOUT = 120
29
+ DEFAULT_MAX_OUTPUT = 40_000
30
+
31
+ module_function
32
+
33
+ # config keys (all optional, string or symbol):
34
+ # provider "local" (default) | "docker"
35
+ # root confinement root (default: Dir.pwd)
36
+ # timeout per-exec wall-clock seconds (default 120)
37
+ # max_output bytes of combined output kept (default 40_000)
38
+ # + provider-specific keys (image/network/memory/cpus/... for docker)
39
+ def build(config = {})
40
+ cfg = Coercion.deep_stringify(config || {})
41
+ root = cfg["root"].to_s.empty? ? Dir.pwd : cfg["root"]
42
+ Env.new(
43
+ boundary: Boundary.new(root),
44
+ provider: provider_for(cfg),
45
+ timeout: Integer(cfg["timeout"] || DEFAULT_TIMEOUT),
46
+ max_output: Integer(cfg["max_output"] || DEFAULT_MAX_OUTPUT)
47
+ )
48
+ end
49
+
50
+ def provider_for(cfg)
51
+ case cfg["provider"].to_s
52
+ when "", "local" then Local.new(shell: cfg["shell"] || "/bin/bash")
53
+ when "docker" then Docker.new(cfg)
54
+ else raise ArgumentError, "unknown sandbox provider: #{cfg["provider"].inspect}"
55
+ end
56
+ end
57
+
58
+ # The per-agent sandbox environment: composes an FS boundary with an exec
59
+ # provider and the default limits. Delegates the boundary surface (resolve /
60
+ # relative / inside?) and adds `exec`. Immutable, safely shared across turns.
61
+ class Env
62
+ attr_reader :boundary, :provider, :root, :timeout, :max_output
63
+
64
+ def initialize(boundary:, provider:, timeout:, max_output:)
65
+ @boundary = boundary
66
+ @provider = provider
67
+ @root = boundary.root
68
+ @timeout = timeout
69
+ @max_output = max_output
70
+ end
71
+
72
+ # --- FS boundary (host-side, always on) ---
73
+ def resolve(path, for_write: false) = @boundary.resolve(path, for_write: for_write)
74
+ def inside?(abs) = @boundary.inside?(abs)
75
+ def relative(abs) = @boundary.relative(abs)
76
+
77
+ # --- command exec (via the provider) -> Result ---
78
+ def exec(command, timeout: @timeout, max_output: @max_output)
79
+ @provider.exec(command, root: @root, timeout: timeout, max_output: max_output)
80
+ end
81
+
82
+ def to_s = "Sandbox(#{@provider}, root=#{@root})"
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Checks a tool call's ARGUMENTS against the tool's JSON Schema, at call time.
5
+ # `violation` returns nil (fine) or ONE message describing what is wrong —
6
+ # the same idiom as EgressGuard, and consumed the same way: DataDefinedTool turns
7
+ # it into `{ error: … }` for the model, so a malformed call is a correctable
8
+ # answer instead of a request that goes out shaped wrong.
9
+ #
10
+ # Why this exists: the schema declares the contract, but nothing used to hold the
11
+ # model to it. A call carrying `["arroz"]` where the schema says
12
+ # `[{query, filters}]` was interpolated into the body as-is, the backend answered
13
+ # 200, and the wrong results came back with no error anywhere. Validating here
14
+ # closes that loop *and* names the fix in the message the model reads next.
15
+ #
16
+ # Scope: the safe subset ToolDefinition already validates
17
+ # (object/array/string/number/integer/boolean + enum + minItems/maxItems). Only what
18
+ # the schema DECLARES is checked; undeclared keys pass (providers add nothing, and
19
+ # `additionalProperties` is a tool author's business, not ours).
20
+ #
21
+ # NEVER coerces. The value the model sent is what reaches the request — the guard
22
+ # only decides whether the call may proceed, so turning it on cannot change the
23
+ # bytes of a call that was already correct.
24
+ module SchemaGuard
25
+ # A scalar the schema calls a number/integer/boolean may arrive as its string
26
+ # form ("2", "true") — providers do that, it is lossless, and rejecting it would
27
+ # break working tools for no gain. Structure (object/array) is NEVER lenient.
28
+ NUMERIC_RE = /\A-?\d+(?:\.\d+)?\z/
29
+ INTEGER_RE = /\A-?\d+\z/
30
+ BOOLEAN_STRINGS = %w[true false].freeze
31
+ MAX_REPORTED = 5
32
+
33
+ module_function
34
+
35
+ # schema: canonical JSON Schema (ToolDefinition#parameters). args: the model's
36
+ # kwargs (symbol keys). -> nil | String.
37
+ def violation(schema, args)
38
+ return nil unless schema.is_a?(Hash)
39
+
40
+ values = Insika::Coercion.deep_stringify(args || {})
41
+ missing = missing_top_level(schema, values)
42
+ return "missing required parameter(s): #{missing.join(', ')}" unless missing.empty?
43
+
44
+ problems = []
45
+ (schema["properties"] || {}).each do |pname, pschema|
46
+ value = values[pname.to_s]
47
+ next if value.nil?
48
+
49
+ problems.concat(check(value, pschema, pname.to_s))
50
+ break if problems.length >= MAX_REPORTED
51
+ end
52
+ return nil if problems.empty?
53
+
54
+ "invalid arguments: #{problems.first(MAX_REPORTED).join('; ')}"
55
+ end
56
+
57
+ # Top-level `required` uses PRESENCE (an empty string is missing), because these
58
+ # values feed `{{placeholder}}` interpolation — an empty one produces a silently
59
+ # broken URL/body. Nested `required` uses JSON Schema semantics (key present),
60
+ # where "" can be a legitimate value.
61
+ def missing_top_level(schema, values)
62
+ Array(schema["required"]).map(&:to_s).reject { |n| Insika::Coercion.present?(values[n]) }
63
+ end
64
+
65
+ # -> [String] problems found at/below `path`.
66
+ def check(value, schema, path)
67
+ return [] unless schema.is_a?(Hash)
68
+
69
+ case schema["type"].to_s
70
+ when "object" then check_object(value, schema, path)
71
+ when "array" then check_array(value, schema, path)
72
+ else check_scalar(value, schema, path)
73
+ end
74
+ end
75
+
76
+ def check_object(value, schema, path)
77
+ return ["#{path}: expected an object, got #{kind(value)}"] unless value.is_a?(Hash)
78
+
79
+ props = schema["properties"] || {}
80
+ missing = Array(schema["required"]).map(&:to_s).reject { |k| value.key?(k) }
81
+ problems = missing.map { |k| "#{path}.#{k}: missing (required)" }
82
+
83
+ props.each do |pname, pschema|
84
+ child = value[pname.to_s]
85
+ next if child.nil?
86
+
87
+ problems.concat(check(child, pschema, "#{path}.#{pname}"))
88
+ end
89
+ problems
90
+ end
91
+
92
+ def check_array(value, schema, path)
93
+ return ["#{path}: expected a list, got #{kind(value)}"] unless value.is_a?(Array)
94
+
95
+ problems = size_problems(value, schema, path)
96
+ problems + value.each_with_index.flat_map { |item, i| check(item, schema["items"], "#{path}[#{i}]") }
97
+ end
98
+
99
+ # minItems/maxItems are the only cardinality the authors actually write (a search
100
+ # that takes "1 or more pairs"), and an empty list is exactly the call that reads as
101
+ # success and returns nothing.
102
+ def size_problems(value, schema, path)
103
+ min = schema["minItems"]
104
+ max = schema["maxItems"]
105
+ problems = []
106
+ problems << "#{path}: needs at least #{min} item(s), got #{value.length}" if min.is_a?(Numeric) && value.length < min
107
+ problems << "#{path}: accepts at most #{max} item(s), got #{value.length}" if max.is_a?(Numeric) && value.length > max
108
+ problems
109
+ end
110
+
111
+ def check_scalar(value, schema, path)
112
+ type = schema["type"].to_s
113
+ return ["#{path}: expected #{type}, got #{kind(value)}"] unless scalar_ok?(value, type)
114
+
115
+ enum = schema["enum"]
116
+ return [] unless enum.is_a?(Array) && !enum.empty?
117
+ return [] if enum.map(&:to_s).include?(value.to_s)
118
+
119
+ ["#{path}: #{value.to_s.inspect} is not one of #{enum.map(&:to_s).join('/')}"]
120
+ end
121
+
122
+ def scalar_ok?(value, type)
123
+ return false if value.is_a?(Hash) || value.is_a?(Array)
124
+
125
+ case type
126
+ when "string" then true # any scalar stringifies losslessly
127
+ when "number" then value.is_a?(Numeric) || NUMERIC_RE.match?(value.to_s)
128
+ when "integer" then value.is_a?(Integer) || INTEGER_RE.match?(value.to_s)
129
+ when "boolean" then [true, false].include?(value) || BOOLEAN_STRINGS.include?(value.to_s)
130
+ else true # unknown type: not ours to police
131
+ end
132
+ end
133
+
134
+ # Name the shape the way a model reads it, not the way Ruby does.
135
+ def kind(value)
136
+ case value
137
+ when Hash then "an object"
138
+ when Array then "a list"
139
+ when String then "a string"
140
+ when Numeric then "a number"
141
+ when true, false then "a boolean"
142
+ when nil then "nothing"
143
+ else value.class.name.downcase
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ # Secret masking on the UI<->Store round-trip. OpenClaw
5
+ # convention: a secret NEVER comes back to the screen in plaintext — it becomes
6
+ # the sentinel `__OCULTO__`. On save, the sentinel coming back means "keep what
7
+ # was already there"; a new string replaces it; `""` clears it. Shared by
8
+ # llm_providers (api keys) and, in the future, MCP instances (credentials).
9
+ module SecretMasking
10
+ SENTINEL = "__OCULTO__"
11
+
12
+ module_function
13
+
14
+ # Value to DISPLAY: present -> sentinel (never the plaintext); absent -> nil.
15
+ def mask(value)
16
+ present?(value) ? SENTINEL : nil
17
+ end
18
+
19
+ # Value to PERSIST given what the form sent (`incoming`) and what already
20
+ # existed (`existing`):
21
+ # - not sent (nil) ............ preserves `existing`
22
+ # - sentinel `__OCULTO__` ..... preserves `existing`
23
+ # - "" (empty) ................ clears (nil)
24
+ # - new string ................ replaces
25
+ def reconcile(incoming, existing)
26
+ return existing if incoming.nil? || incoming == SENTINEL
27
+
28
+ s = incoming.to_s
29
+ s.empty? ? nil : s
30
+ end
31
+
32
+ def present?(value) = Insika::Coercion.present?(value)
33
+ end
34
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Insika
4
+ module Server
5
+ module A2A
6
+ # A2A AgentCard: discovery for the exposed agent. One agent
7
+ # per deployment; HONEST capabilities (streaming/push false in this slice).
8
+ module AgentCard
9
+ def self.build(agent:, base_url:, skills: [], version: "0.1.0")
10
+ {
11
+ name: agent.id,
12
+ description: agent.base_prompt.to_s[0, 280],
13
+ url: "#{base_url}/a2a",
14
+ version: version,
15
+ protocolVersion: "0.2.5", # A2A wire — to confirm
16
+ capabilities: { streaming: false, pushNotifications: false, stateTransitionHistory: false },
17
+ defaultInputModes: ["text/plain"],
18
+ defaultOutputModes: ["text/plain"],
19
+ skills: Array(skills).map do |s|
20
+ { id: s.name, name: s.name, description: s.description, tags: [] }
21
+ end
22
+ }
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end