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,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
|