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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +361 -0
- data/LICENSE +21 -0
- data/README.md +136 -2
- data/bin/insika +366 -0
- data/docs/AGENTS.md +618 -0
- data/docs/ARCHITECTURE.md +333 -0
- data/docs/BENCHMARK.md +114 -0
- data/docs/CHANNELS.md +453 -0
- data/docs/CONTEXT.md +117 -0
- data/docs/DEPLOY.md +354 -0
- data/docs/EMBEDDING.md +198 -0
- data/docs/EVALS.md +273 -0
- data/docs/LOADTEST.md +232 -0
- data/docs/OBSERVABILITY.md +374 -0
- data/docs/PLUGINS.md +211 -0
- data/docs/REFINEMENT.md +477 -0
- data/docs/RELEASING.md +70 -0
- data/docs/RUNNING-LOCAL.md +153 -0
- data/docs/SANDBOX.md +114 -0
- data/docs/SECURITY.md +375 -0
- data/docs/SKILLS.md +284 -0
- data/docs/TOOLS.md +302 -0
- data/docs/WHY.md +137 -0
- data/docs/WORKFLOWS.md +225 -0
- data/docs/build.md +14 -0
- data/docs/index.md +68 -0
- data/docs/onboarding/start.md +126 -0
- data/docs/operate.md +12 -0
- data/docs/ship.md +10 -0
- data/docs/understand.md +10 -0
- data/lib/insika/agent_file_store.rb +125 -0
- data/lib/insika/agent_profile.rb +255 -0
- data/lib/insika/alert_dispatcher.rb +139 -0
- data/lib/insika/allowlist.rb +28 -0
- data/lib/insika/baseline_store.rb +74 -0
- data/lib/insika/budget_ledger.rb +135 -0
- data/lib/insika/capability/resolved_tool.rb +34 -0
- data/lib/insika/capability_registry.rb +112 -0
- data/lib/insika/channel_delivery.rb +153 -0
- data/lib/insika/channel_registry.rb +30 -0
- data/lib/insika/channels/relay.rb +178 -0
- data/lib/insika/channels/web/widget.js +283 -0
- data/lib/insika/channels/web.rb +211 -0
- data/lib/insika/channels/webhook.rb +58 -0
- data/lib/insika/chat_builder.rb +303 -0
- data/lib/insika/checkpoint.rb +13 -0
- data/lib/insika/checkpoint_store.rb +153 -0
- data/lib/insika/circuit_state.rb +114 -0
- data/lib/insika/coercion.rb +58 -0
- data/lib/insika/command.rb +32 -0
- data/lib/insika/command_bus.rb +39 -0
- data/lib/insika/commands/agent_payload.rb +43 -0
- data/lib/insika/commands/approve_action.rb +46 -0
- data/lib/insika/commands/cancel_task.rb +33 -0
- data/lib/insika/commands/create_agent.rb +54 -0
- data/lib/insika/commands/create_session.rb +67 -0
- data/lib/insika/commands/delete_agent.rb +33 -0
- data/lib/insika/commands/delete_agent_file.rb +50 -0
- data/lib/insika/commands/delete_data_tool.rb +33 -0
- data/lib/insika/commands/delete_llm_provider.rb +36 -0
- data/lib/insika/commands/delete_mcp.rb +30 -0
- data/lib/insika/commands/delete_skill.rb +43 -0
- data/lib/insika/commands/delete_system_file.rb +29 -0
- data/lib/insika/commands/gate_refinement.rb +245 -0
- data/lib/insika/commands/import_mcp_tools.rb +48 -0
- data/lib/insika/commands/import_tools.rb +81 -0
- data/lib/insika/commands/issue_tenant_token.rb +41 -0
- data/lib/insika/commands/memory_add_note.rb +32 -0
- data/lib/insika/commands/memory_forget_fact.rb +32 -0
- data/lib/insika/commands/memory_put_fact.rb +35 -0
- data/lib/insika/commands/pause_task.rb +29 -0
- data/lib/insika/commands/resolve_refinement.rb +126 -0
- data/lib/insika/commands/restore_agent_file.rb +36 -0
- data/lib/insika/commands/restore_data_tool.rb +34 -0
- data/lib/insika/commands/restore_system_file.rb +31 -0
- data/lib/insika/commands/resume_task.rb +85 -0
- data/lib/insika/commands/revoke_token.rb +39 -0
- data/lib/insika/commands/rotate_tenant_token.rb +43 -0
- data/lib/insika/commands/run_refinement.rb +133 -0
- data/lib/insika/commands/send_message.rb +150 -0
- data/lib/insika/commands/set_agent_tools.rb +39 -0
- data/lib/insika/commands/set_skill_agents.rb +112 -0
- data/lib/insika/commands/trigger_workflow.rb +80 -0
- data/lib/insika/commands/update_agent.rb +49 -0
- data/lib/insika/commands/update_settings.rb +33 -0
- data/lib/insika/commands/upsert_llm_provider.rb +34 -0
- data/lib/insika/commands/upsert_mcp.rb +32 -0
- data/lib/insika/commands/write_agent_file.rb +57 -0
- data/lib/insika/commands/write_data_tool.rb +43 -0
- data/lib/insika/commands/write_golden.rb +58 -0
- data/lib/insika/commands/write_skill.rb +60 -0
- data/lib/insika/commands/write_system_file.rb +31 -0
- data/lib/insika/config_store.rb +89 -0
- data/lib/insika/context/builder.rb +166 -0
- data/lib/insika/context/catalog_provider.rb +23 -0
- data/lib/insika/context/fragment.rb +43 -0
- data/lib/insika/context/priority.rb +30 -0
- data/lib/insika/context/provider.rb +19 -0
- data/lib/insika/context/providers/memory.rb +60 -0
- data/lib/insika/context/providers/prompt.rb +105 -0
- data/lib/insika/context/providers/request.rb +32 -0
- data/lib/insika/context/providers/session.rb +123 -0
- data/lib/insika/context/providers/skill.rb +24 -0
- data/lib/insika/context/providers/skill_trigger.rb +128 -0
- data/lib/insika/context/providers/tool_search.rb +20 -0
- data/lib/insika/context_trace_store.rb +92 -0
- data/lib/insika/delegation_store.rb +153 -0
- data/lib/insika/doctor.rb +539 -0
- data/lib/insika/dsl/definition.rb +55 -0
- data/lib/insika/dsl/runtime.rb +382 -0
- data/lib/insika/dsl/server_boot.rb +98 -0
- data/lib/insika/dsl/system.rb +93 -0
- data/lib/insika/dsl/workflow_adapter.rb +59 -0
- data/lib/insika/dsl.rb +364 -0
- data/lib/insika/edge_limiter.rb +268 -0
- data/lib/insika/egress_guard.rb +75 -0
- data/lib/insika/env_schema.rb +249 -0
- data/lib/insika/errors.rb +201 -0
- data/lib/insika/evals/assertions.rb +247 -0
- data/lib/insika/evals/baseline.rb +69 -0
- data/lib/insika/evals/golden.rb +172 -0
- data/lib/insika/evals/judge.rb +225 -0
- data/lib/insika/evals/pairwise.rb +178 -0
- data/lib/insika/evals/report.rb +115 -0
- data/lib/insika/evals/runner.rb +141 -0
- data/lib/insika/evals/transport.rb +178 -0
- data/lib/insika/event.rb +18 -0
- data/lib/insika/event_stream.rb +132 -0
- data/lib/insika/executor.rb +1995 -0
- data/lib/insika/frontmatter.rb +42 -0
- data/lib/insika/golden_store.rb +145 -0
- data/lib/insika/hooks.rb +48 -0
- data/lib/insika/http_client.rb +63 -0
- data/lib/insika/inbound_log.rb +84 -0
- data/lib/insika/llm_configurator.rb +99 -0
- data/lib/insika/llm_provider_store.rb +83 -0
- data/lib/insika/loop_detector.rb +143 -0
- data/lib/insika/mcp_http_client.rb +67 -0
- data/lib/insika/mcp_store.rb +115 -0
- data/lib/insika/mcp_tool_ingestor.rb +143 -0
- data/lib/insika/memory_store.rb +93 -0
- data/lib/insika/message_origin.rb +76 -0
- data/lib/insika/middleware.rb +36 -0
- data/lib/insika/model_policy.rb +52 -0
- data/lib/insika/model_resolver.rb +176 -0
- data/lib/insika/model_selection.rb +115 -0
- data/lib/insika/onboarding.rb +208 -0
- data/lib/insika/outbox_store.rb +166 -0
- data/lib/insika/overlay_tool_registry.rb +102 -0
- data/lib/insika/pack.rb +102 -0
- data/lib/insika/pack_importer.rb +123 -0
- data/lib/insika/pending_action_store.rb +120 -0
- data/lib/insika/plugin/loader.rb +356 -0
- data/lib/insika/plugin.rb +35 -0
- data/lib/insika/policy/engine.rb +83 -0
- data/lib/insika/policy/policy.rb +120 -0
- data/lib/insika/policy_registry.rb +23 -0
- data/lib/insika/profile_source.rb +143 -0
- data/lib/insika/prompt_catalog.rb +61 -0
- data/lib/insika/provider_error_classifier.rb +160 -0
- data/lib/insika/queue_policy.rb +167 -0
- data/lib/insika/recovery.rb +168 -0
- data/lib/insika/refinement/candidate.rb +159 -0
- data/lib/insika/refinement/evidence_collector.rb +371 -0
- data/lib/insika/refinement/gate.rb +234 -0
- data/lib/insika/refinement/panel.rb +222 -0
- data/lib/insika/refinement/proposer.rb +262 -0
- data/lib/insika/refinement_store.rb +295 -0
- data/lib/insika/registry.rb +59 -0
- data/lib/insika/reliability.rb +185 -0
- data/lib/insika/safety/config.rb +109 -0
- data/lib/insika/safety/detectors.rb +176 -0
- data/lib/insika/safety/factory.rb +102 -0
- data/lib/insika/safety/input_guardrail.rb +102 -0
- data/lib/insika/safety/moderator.rb +94 -0
- data/lib/insika/safety/output_filter.rb +79 -0
- data/lib/insika/safety/output_validator.rb +101 -0
- data/lib/insika/safety/safe_responses.rb +47 -0
- data/lib/insika/sandbox/boundary.rb +93 -0
- data/lib/insika/sandbox/docker.rb +74 -0
- data/lib/insika/sandbox/local.rb +33 -0
- data/lib/insika/sandbox/runner.rb +80 -0
- data/lib/insika/sandbox.rb +85 -0
- data/lib/insika/schema_guard.rb +147 -0
- data/lib/insika/secret_masking.rb +34 -0
- data/lib/insika/server/a2a/agent_card.rb +27 -0
- data/lib/insika/server/a2a/app.rb +112 -0
- data/lib/insika/server/a2a/client.rb +101 -0
- data/lib/insika/server/a2a/errors.rb +32 -0
- data/lib/insika/server/a2a/http.rb +42 -0
- data/lib/insika/server/a2a/message.rb +27 -0
- data/lib/insika/server/a2a/protocol.rb +45 -0
- data/lib/insika/server/a2a/remotes.rb +25 -0
- data/lib/insika/server/a2a/task_projection.rb +40 -0
- data/lib/insika/server/app.rb +1022 -0
- data/lib/insika/server/boot.rb +119 -0
- data/lib/insika/server/rack_app.rb +118 -0
- data/lib/insika/server/responses.rb +165 -0
- data/lib/insika/server/sse_body.rb +96 -0
- data/lib/insika/server/tenant_auth.rb +61 -0
- data/lib/insika/session_actor.rb +162 -0
- data/lib/insika/session_store.rb +143 -0
- data/lib/insika/settings_store.rb +154 -0
- data/lib/insika/shutdown.rb +125 -0
- data/lib/insika/skill_catalog.rb +220 -0
- data/lib/insika/skill_store.rb +127 -0
- data/lib/insika/steer_injector.rb +110 -0
- data/lib/insika/store.rb +52 -0
- data/lib/insika/stores/memory.rb +123 -0
- data/lib/insika/stores/sqlite.rb +183 -0
- data/lib/insika/studio/app.rb +1693 -0
- data/lib/insika/studio/assets/dist/application.css +1 -0
- data/lib/insika/studio/assets/dist/application.js +70 -0
- data/lib/insika/studio/forms.rb +335 -0
- data/lib/insika/studio/nav_icons.rb +31 -0
- data/lib/insika/studio/views/_message.erb +44 -0
- data/lib/insika/studio/views/agent_detail.erb +285 -0
- data/lib/insika/studio/views/agents.erb +63 -0
- data/lib/insika/studio/views/approvals.erb +41 -0
- data/lib/insika/studio/views/chats.erb +34 -0
- data/lib/insika/studio/views/evals.erb +83 -0
- data/lib/insika/studio/views/home.erb +72 -0
- data/lib/insika/studio/views/layout.erb +94 -0
- data/lib/insika/studio/views/login.erb +17 -0
- data/lib/insika/studio/views/mcp.erb +91 -0
- data/lib/insika/studio/views/not_found.erb +5 -0
- data/lib/insika/studio/views/playground.erb +47 -0
- data/lib/insika/studio/views/refinement.erb +234 -0
- data/lib/insika/studio/views/session.erb +137 -0
- data/lib/insika/studio/views/settings.erb +168 -0
- data/lib/insika/studio/views/skills.erb +141 -0
- data/lib/insika/studio/views/system_files.erb +65 -0
- data/lib/insika/studio/views/task.erb +105 -0
- data/lib/insika/studio/views/tasks.erb +33 -0
- data/lib/insika/studio/views/tool_edit.erb +107 -0
- data/lib/insika/studio/views/tools.erb +89 -0
- data/lib/insika/subagent_graph.rb +96 -0
- data/lib/insika/system_file_store.rb +96 -0
- data/lib/insika/task_actor.rb +128 -0
- data/lib/insika/task_store.rb +250 -0
- data/lib/insika/telemetry/pricing.rb +104 -0
- data/lib/insika/telemetry/recorder.rb +228 -0
- data/lib/insika/telemetry.rb +127 -0
- data/lib/insika/testing/store_contract.rb +270 -0
- data/lib/insika/tick.rb +122 -0
- data/lib/insika/token_estimator.rb +16 -0
- data/lib/insika/token_store.rb +168 -0
- data/lib/insika/tool_assembly.rb +140 -0
- data/lib/insika/tool_catalog.rb +89 -0
- data/lib/insika/tool_definition.rb +518 -0
- data/lib/insika/tool_envelope.rb +140 -0
- data/lib/insika/tool_manifest.rb +218 -0
- data/lib/insika/tool_output_compressor.rb +100 -0
- data/lib/insika/tool_registry.rb +21 -0
- data/lib/insika/tool_store.rb +135 -0
- data/lib/insika/tool_trace_store.rb +92 -0
- data/lib/insika/tools/a2a_remote.rb +48 -0
- data/lib/insika/tools/agent_enum.rb +68 -0
- data/lib/insika/tools/concurrency.rb +54 -0
- data/lib/insika/tools/data_defined_tool.rb +219 -0
- data/lib/insika/tools/load_skill.rb +99 -0
- data/lib/insika/tools/remember.rb +53 -0
- data/lib/insika/tools/stuck_signal.rb +44 -0
- data/lib/insika/tools/subagent.rb +75 -0
- data/lib/insika/tools/subagents.rb +77 -0
- data/lib/insika/tools/tool_search.rb +94 -0
- data/lib/insika/turn_output.rb +139 -0
- data/lib/insika/turn_state.rb +162 -0
- data/lib/insika/turn_timing.rb +56 -0
- data/lib/insika/usage_ledger.rb +47 -0
- data/lib/insika/version.rb +3 -1
- data/lib/insika/wiring/graph.rb +249 -0
- data/lib/insika/workflow.rb +185 -0
- data/lib/insika/workflow_registry.rb +33 -0
- data/lib/insika.rb +220 -4
- 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
|