insika 0.0.1 → 0.1.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 +295 -0
- data/LICENSE +21 -0
- data/README.md +136 -2
- data/bin/insika +351 -0
- data/docs/AGENTS.md +494 -0
- data/docs/ARCHITECTURE.md +333 -0
- data/docs/BENCHMARK.md +114 -0
- data/docs/CHANNELS.md +453 -0
- data/docs/CONTEXT.md +100 -0
- data/docs/DEPLOY.md +334 -0
- data/docs/EMBEDDING.md +194 -0
- data/docs/EVALS.md +273 -0
- data/docs/LOADTEST.md +231 -0
- data/docs/OBSERVABILITY.md +365 -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 +362 -0
- data/docs/SKILLS.md +98 -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 +188 -0
- data/lib/insika/allowlist.rb +28 -0
- data/lib/insika/baseline_store.rb +74 -0
- data/lib/insika/capability/resolved_tool.rb +34 -0
- data/lib/insika/capability_registry.rb +112 -0
- data/lib/insika/channel_delivery.rb +150 -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/chat_builder.rb +254 -0
- data/lib/insika/checkpoint.rb +13 -0
- data/lib/insika/checkpoint_store.rb +153 -0
- data/lib/insika/coercion.rb +50 -0
- data/lib/insika/command.rb +32 -0
- data/lib/insika/command_bus.rb +39 -0
- data/lib/insika/commands/agent_payload.rb +41 -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_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/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/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 +71 -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 +50 -0
- data/lib/insika/commands/write_system_file.rb +31 -0
- data/lib/insika/config_store.rb +85 -0
- data/lib/insika/context/builder.rb +166 -0
- data/lib/insika/context/catalog_provider.rb +23 -0
- data/lib/insika/context/fragment.rb +19 -0
- data/lib/insika/context/priority.rb +29 -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 +108 -0
- data/lib/insika/context/providers/skill.rb +20 -0
- data/lib/insika/context/providers/tool_search.rb +20 -0
- data/lib/insika/delegation_store.rb +153 -0
- data/lib/insika/doctor.rb +294 -0
- data/lib/insika/dsl/definition.rb +55 -0
- data/lib/insika/dsl/runtime.rb +379 -0
- data/lib/insika/dsl/server_boot.rb +97 -0
- data/lib/insika/dsl/system.rb +93 -0
- data/lib/insika/dsl/workflow_adapter.rb +59 -0
- data/lib/insika/dsl.rb +307 -0
- data/lib/insika/edge_limiter.rb +130 -0
- data/lib/insika/egress_guard.rb +75 -0
- data/lib/insika/env_schema.rb +246 -0
- data/lib/insika/errors.rb +145 -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 +114 -0
- data/lib/insika/executor.rb +1680 -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/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 +114 -0
- data/lib/insika/onboarding.rb +208 -0
- data/lib/insika/outbox_store.rb +166 -0
- data/lib/insika/overlay_tool_registry.rb +103 -0
- data/lib/insika/pack.rb +102 -0
- data/lib/insika/pack_importer.rb +121 -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 +137 -0
- data/lib/insika/prompt_catalog.rb +61 -0
- data/lib/insika/queue_policy.rb +167 -0
- data/lib/insika/recovery.rb +127 -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/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 +87 -0
- data/lib/insika/safety/moderator.rb +86 -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/admin_auth.rb +29 -0
- data/lib/insika/server/app.rb +850 -0
- data/lib/insika/server/boot.rb +119 -0
- data/lib/insika/server/rack_app.rb +110 -0
- data/lib/insika/server/responses.rb +155 -0
- data/lib/insika/server/sse_body.rb +96 -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 +113 -0
- data/lib/insika/skill_store.rb +79 -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 +1571 -0
- data/lib/insika/studio/assets/dist/application.css +1 -0
- data/lib/insika/studio/assets/dist/application.js +69 -0
- data/lib/insika/studio/forms.rb +340 -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 +62 -0
- data/lib/insika/studio/views/settings.erb +173 -0
- data/lib/insika/studio/views/skills.erb +86 -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/token_estimator.rb +16 -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_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 +220 -0
- data/lib/insika/tools/load_skill.rb +41 -0
- data/lib/insika/tools/remember.rb +53 -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 +158 -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 +198 -0
- data/lib/insika/workflow.rb +185 -0
- data/lib/insika/workflow_registry.rb +33 -0
- data/lib/insika.rb +203 -4
- metadata +395 -8
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Insika
|
|
7
|
+
# REFINEMENT DOMAIN store (RFC-0013, phase A). One record per refinement RUN:
|
|
8
|
+
# the window that was read, the ranked findings the EvidenceCollector produced,
|
|
9
|
+
# and the run's outcome. RUNTIME data (it is derived from sessions/tasks/traces),
|
|
10
|
+
# so it takes the raw `store:` like SessionStore/TaskStore — not the ConfigStore.
|
|
11
|
+
#
|
|
12
|
+
# The key embeds the agent and the start timestamp:
|
|
13
|
+
# "run:<agent_id>:<started_at>:<id>"
|
|
14
|
+
# so `list(SCOPE, "run:<agent>:")` comes back CHRONOLOGICAL for that agent (the
|
|
15
|
+
# Store contract orders lexicographically) and `latest_for` is its last element.
|
|
16
|
+
# An agent id containing ":" would break that split, so it is rejected on write.
|
|
17
|
+
#
|
|
18
|
+
# Phase A wrote no edits anywhere — a Run was a REPORT and every non-collecting
|
|
19
|
+
# status was terminal. Phase C (RFC-0013 §3.2) adds the rest of the lifecycle on
|
|
20
|
+
# the SAME record, additively: a gated candidate and the operator's decision.
|
|
21
|
+
#
|
|
22
|
+
# collecting ─▶ completed ─▶ gating ─▶ awaiting_approval ─▶ applied
|
|
23
|
+
# ╰─▶ no_findings ╰─▶ rejected (the gate failed it,
|
|
24
|
+
# ╰─▶ failed or the operator did)
|
|
25
|
+
#
|
|
26
|
+
# **The approval lives here, not in `PendingActionStore`** — a deliberate deviation
|
|
27
|
+
# from §3.6. That store is coupled to a suspended TURN: `ApproveAction` resolves
|
|
28
|
+
# the record and then calls `executor.approve(task_id)` to wake a fiber. A
|
|
29
|
+
# refinement proposal has no turn and no fiber, so reusing it would mean inventing
|
|
30
|
+
# a task id, a fake `tool` name, and a wake-up that must never do anything — and
|
|
31
|
+
# the operator's approvals inbox would fill with rows that are not tool calls. The
|
|
32
|
+
# property §3.6 actually wanted is durability across a `kill -9`, and this store
|
|
33
|
+
# has had it since phase A.
|
|
34
|
+
class RefinementStore
|
|
35
|
+
include Coercion
|
|
36
|
+
|
|
37
|
+
SCOPE = "refinements"
|
|
38
|
+
KEY_PREFIX = "run:"
|
|
39
|
+
|
|
40
|
+
STATUSES = %i[collecting completed gating awaiting_approval applied rejected
|
|
41
|
+
no_findings failed].freeze
|
|
42
|
+
|
|
43
|
+
# OPEN means "this run will change without anyone asking": work is in flight
|
|
44
|
+
# (`collecting`, `gating`) or a human owes it an answer (`awaiting_approval`).
|
|
45
|
+
# Everything else is terminal — INCLUDING `completed`, which phase A already
|
|
46
|
+
# treated that way and which stays true: a report is finished, and gating one is
|
|
47
|
+
# a new deliberate action, not a continuation. (Widening `terminal?` here is what
|
|
48
|
+
# the Studio's "latest report" lookup reads, so getting it wrong hides the report.)
|
|
49
|
+
OPEN = %i[collecting gating awaiting_approval].freeze
|
|
50
|
+
|
|
51
|
+
# `candidate`/`gate` are the WINNER — the one proposal a human is asked about, and
|
|
52
|
+
# the only thing `ResolveRefinement` ever applies. `candidates` is the whole panel
|
|
53
|
+
# (RFC-0013 §3.9, phase D): every candidate that was built, who wrote it, and how
|
|
54
|
+
# it scored, including the ones that lost and the ones the budget never gated. A
|
|
55
|
+
# phase-C run recorded one candidate and no panel; it still reads back correctly,
|
|
56
|
+
# because a panel of one is the same shape.
|
|
57
|
+
Run = Data.define(:id, :agent_id, :status, :window, :findings, :excluded,
|
|
58
|
+
:started_at, :finished_at, :error,
|
|
59
|
+
:candidate, :candidates, :gate, :cost, :decision) do
|
|
60
|
+
def terminal? = !OPEN.include?(status)
|
|
61
|
+
def findings_count = findings.size
|
|
62
|
+
|
|
63
|
+
# Is there a gated proposal waiting for a human? The one question the Studio
|
|
64
|
+
# and the apply command both ask.
|
|
65
|
+
def awaiting_approval? = status == :awaiting_approval
|
|
66
|
+
def gate_passed? = gate.is_a?(Hash) && gate["passed"] == true
|
|
67
|
+
def edits = (candidate || {})["edits"] || []
|
|
68
|
+
def panel = Array(candidates)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def initialize(store:)
|
|
72
|
+
@store = store
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Opens a run (:collecting). `window` is the collector's window as data
|
|
76
|
+
# ({ "last_sessions" => N } | { "since" => iso8601 }) — recorded so a report can
|
|
77
|
+
# be read months later and still say what it looked at. -> Run.
|
|
78
|
+
def create(agent_id:, window: {}, id: SecureRandom.uuid, at: nil)
|
|
79
|
+
agent = agent_id.to_s
|
|
80
|
+
raise Insika::ValidationError, "agent_id is required" if agent.empty?
|
|
81
|
+
raise Insika::ValidationError, "agent_id must not contain ':'" if agent.include?(":")
|
|
82
|
+
|
|
83
|
+
started = at || timestamp
|
|
84
|
+
record = {
|
|
85
|
+
"id" => id.to_s, "agent_id" => agent, "status" => "collecting",
|
|
86
|
+
"window" => deep_stringify(window || {}), "findings" => [], "excluded" => 0,
|
|
87
|
+
"started_at" => started, "finished_at" => nil, "error" => nil,
|
|
88
|
+
"candidate" => nil, "candidates" => [], "gate" => nil, "cost" => nil,
|
|
89
|
+
"decision" => nil
|
|
90
|
+
}
|
|
91
|
+
@store.set(SCOPE, key_for(agent, started, id), record)
|
|
92
|
+
to_run(record)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Closes a run with its findings. Empty findings -> :no_findings (a distinct
|
|
96
|
+
# outcome from :completed — "we looked and it was clean" is a real answer, not a
|
|
97
|
+
# failure). -> Run. ArgumentError if the run is already terminal.
|
|
98
|
+
# `excluded` is how many turns the window dropped on purpose (synthetic
|
|
99
|
+
# traffic) — recorded so a report never reads cleaner than the data was.
|
|
100
|
+
def complete(id, findings:, excluded: 0)
|
|
101
|
+
update(id) do |record|
|
|
102
|
+
guard_open!(record)
|
|
103
|
+
list = Array(findings).map { |f| deep_stringify(f.respond_to?(:to_h) ? f.to_h : f) }
|
|
104
|
+
record["findings"] = list
|
|
105
|
+
record["excluded"] = Integer(excluded)
|
|
106
|
+
record["status"] = list.empty? ? "no_findings" : "completed"
|
|
107
|
+
record["finished_at"] = timestamp
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Closes a run as :failed, recording the error. -> Run.
|
|
112
|
+
def fail(id, error:)
|
|
113
|
+
update(id) do |record|
|
|
114
|
+
guard_open!(record)
|
|
115
|
+
record["status"] = "failed"
|
|
116
|
+
record["error"] = error.to_s
|
|
117
|
+
record["finished_at"] = timestamp
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# -- phase C: the proposal's lifecycle -------------------------------
|
|
122
|
+
|
|
123
|
+
# Attaches the candidate(s) under gate and moves the run to :gating. Only a
|
|
124
|
+
# `completed` run can be gated: a report with no findings has nothing to propose
|
|
125
|
+
# from, and a failed one never finished looking.
|
|
126
|
+
#
|
|
127
|
+
# `candidate:` (one) and `candidates:` (a panel) are the same call — the panel is
|
|
128
|
+
# recorded before the gate runs so the Studio can show WHAT is being scored while
|
|
129
|
+
# it is being scored, which is minutes of real replay. No winner is claimed yet:
|
|
130
|
+
# `candidate` stays nil until `gated` says which one it is. -> Run.
|
|
131
|
+
def gating(id, candidate: nil, candidates: nil)
|
|
132
|
+
panel = Array(candidates || [candidate].compact)
|
|
133
|
+
raise Insika::ValidationError, "a candidate is required to gate" if panel.empty?
|
|
134
|
+
|
|
135
|
+
update(id) do |record|
|
|
136
|
+
unless record["status"] == "completed"
|
|
137
|
+
raise ArgumentError, "run #{record['id']} is #{record['status']}, expected completed"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
record["candidates"] = panel.map { |c| entry_for(c) }
|
|
141
|
+
record["candidate"] = nil
|
|
142
|
+
record["gate"] = nil
|
|
143
|
+
record["status"] = "gating"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Records the gate's verdict. A PASS parks the run at :awaiting_approval — a
|
|
148
|
+
# human still has to say yes, which is the product and not a formality (D2). A
|
|
149
|
+
# FAIL is terminal as :rejected, with the gate report as the stated reason: the
|
|
150
|
+
# same finding must re-surface with new evidence before anything is proposed
|
|
151
|
+
# again, so there is no silent retry loop (§3.6).
|
|
152
|
+
#
|
|
153
|
+
# `report` is the WINNER's (or, when nothing survived, the most informative
|
|
154
|
+
# refusal). `panel` is every scored entry — the store attaches it as-is and picks
|
|
155
|
+
# the winning candidate out of it by id. Which candidate WON is the caller's
|
|
156
|
+
# ranking decision (§3.5); this store does not rank, it records. -> Run.
|
|
157
|
+
def gated(id, report:, panel: nil, cost: nil)
|
|
158
|
+
update(id) do |record|
|
|
159
|
+
unless record["status"] == "gating"
|
|
160
|
+
raise ArgumentError, "run #{record['id']} is #{record['status']}, expected gating"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
gate = deep_stringify(report.respond_to?(:to_h) ? report.to_h : report)
|
|
164
|
+
record["candidates"] = panel.map { |e| entry_for(e) } if panel
|
|
165
|
+
record["candidate"] = winning_candidate(record, gate)
|
|
166
|
+
record["gate"] = gate
|
|
167
|
+
record["cost"] = deep_stringify(cost.respond_to?(:to_h) ? cost.to_h : cost) if cost
|
|
168
|
+
if gate["passed"]
|
|
169
|
+
record["status"] = "awaiting_approval"
|
|
170
|
+
else
|
|
171
|
+
record["status"] = "rejected"
|
|
172
|
+
record["decision"] = { "by" => "gate", "at" => timestamp, "note" => gate["reason"] }
|
|
173
|
+
record["finished_at"] = timestamp
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# The operator's answer to a gated proposal. `applied` is recorded only after the
|
|
179
|
+
# writes land, so a crash between the two leaves the run awaiting approval and
|
|
180
|
+
# the operator re-approves — replaying a write that is already versioned and
|
|
181
|
+
# idempotent-ish beats recording a lie. -> Run.
|
|
182
|
+
def resolve(id, decision:, operator: nil, note: nil)
|
|
183
|
+
target = decision.to_sym
|
|
184
|
+
unless %i[applied rejected].include?(target)
|
|
185
|
+
raise Insika::ValidationError, "invalid decision: #{decision} (applied|rejected)"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
update(id) do |record|
|
|
189
|
+
unless record["status"] == "awaiting_approval"
|
|
190
|
+
raise ArgumentError, "run #{record['id']} is #{record['status']}, expected awaiting_approval"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
record["status"] = target.to_s
|
|
194
|
+
record["decision"] = { "by" => (Coercion.presence(operator) || "operator").to_s,
|
|
195
|
+
"at" => timestamp, "note" => Coercion.presence(note) }.compact
|
|
196
|
+
record["finished_at"] = timestamp
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# -> [Run] every run parked on a human, most recent first. What the Studio badges.
|
|
201
|
+
def awaiting_approval(limit: 20)
|
|
202
|
+
recent(limit: 200).select(&:awaiting_approval?).first(limit)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# -> Run | nil. O(n) scan over the scope (the key carries agent+timestamp, so
|
|
206
|
+
# there is no index by id): one node, local SQLite, runs are operator-paced.
|
|
207
|
+
def find(id)
|
|
208
|
+
key = key_for_id(id)
|
|
209
|
+
key && to_run(@store.get(SCOPE, key))
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# -> [Run] for one agent, MOST RECENT FIRST, capped by `limit`.
|
|
213
|
+
def for_agent(agent_id, limit: nil)
|
|
214
|
+
keys = @store.list(SCOPE, "#{KEY_PREFIX}#{agent_id}:").reverse
|
|
215
|
+
keys = keys.first(limit) if limit
|
|
216
|
+
keys.filter_map { |k| to_run(@store.get(SCOPE, k)) }
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# -> Run | nil (the agent's most recent run, whatever its status).
|
|
220
|
+
def latest_for(agent_id) = for_agent(agent_id, limit: 1).first
|
|
221
|
+
|
|
222
|
+
# -> [Run] across every agent, most recent first, capped.
|
|
223
|
+
def recent(limit: 20)
|
|
224
|
+
@store.list(SCOPE, KEY_PREFIX)
|
|
225
|
+
.filter_map { |k| to_run(@store.get(SCOPE, k)) }
|
|
226
|
+
.sort_by { |r| r.started_at.to_s }.reverse.first(limit)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
private
|
|
230
|
+
|
|
231
|
+
# One panel row: `{ candidate:, proposers: [], gate: }`. A bare Candidate (phase
|
|
232
|
+
# C, an operator's own payload, an older record) is wrapped into the same shape,
|
|
233
|
+
# so every reader has ONE thing to read and the two eras of this record do not
|
|
234
|
+
# each need a branch in the Studio.
|
|
235
|
+
def entry_for(value)
|
|
236
|
+
raw = deep_stringify(value.respond_to?(:to_h) ? value.to_h : value)
|
|
237
|
+
return raw if raw.key?("candidate")
|
|
238
|
+
|
|
239
|
+
{ "candidate" => raw, "proposers" => [raw["proposer"]].compact, "gate" => nil }
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# The candidate the winning report belongs to, matched by id. A report whose
|
|
243
|
+
# candidate is not in the panel (an older record, a hand-built payload) leaves
|
|
244
|
+
# whatever was already there rather than inventing a winner.
|
|
245
|
+
def winning_candidate(record, gate)
|
|
246
|
+
match = Array(record["candidates"]).find do |entry|
|
|
247
|
+
(entry["candidate"] || {})["id"] == gate["candidate_id"]
|
|
248
|
+
end
|
|
249
|
+
match ? match["candidate"] : record["candidate"]
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def key_for(agent, started_at, id) = "#{KEY_PREFIX}#{agent}:#{started_at}:#{id}"
|
|
253
|
+
|
|
254
|
+
# The id is the key's last segment; scanning is the price of keeping the key
|
|
255
|
+
# chronological (which is what every read except `find` actually wants).
|
|
256
|
+
def key_for_id(id)
|
|
257
|
+
suffix = ":#{id}"
|
|
258
|
+
@store.list(SCOPE, KEY_PREFIX).find { |k| k.end_with?(suffix) }
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def update(id)
|
|
262
|
+
key = key_for_id(id)
|
|
263
|
+
raise Insika::NotFoundError, "refinement run not found: #{id}" if key.nil?
|
|
264
|
+
|
|
265
|
+
record = @store.get(SCOPE, key)
|
|
266
|
+
raise Insika::NotFoundError, "refinement run not found: #{id}" if record.nil?
|
|
267
|
+
|
|
268
|
+
yield record
|
|
269
|
+
@store.set(SCOPE, key, record)
|
|
270
|
+
to_run(record)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def guard_open!(record)
|
|
274
|
+
return if record["status"] == "collecting"
|
|
275
|
+
|
|
276
|
+
raise ArgumentError, "run #{record['id']} is already #{record['status']}"
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def to_run(record)
|
|
280
|
+
return nil if record.nil?
|
|
281
|
+
|
|
282
|
+
Run.new(
|
|
283
|
+
id: record["id"], agent_id: record["agent_id"],
|
|
284
|
+
status: record["status"].to_sym, window: record["window"] || {},
|
|
285
|
+
findings: record["findings"] || [], excluded: record["excluded"] || 0,
|
|
286
|
+
started_at: record["started_at"], finished_at: record["finished_at"],
|
|
287
|
+
error: record["error"],
|
|
288
|
+
candidate: record["candidate"], candidates: record["candidates"] || [],
|
|
289
|
+
gate: record["gate"], cost: record["cost"], decision: record["decision"]
|
|
290
|
+
)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def timestamp = Time.now.utc.iso8601
|
|
294
|
+
end
|
|
295
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
# Generic executable base: Registry =
|
|
5
|
+
# EXECUTABLE content (tools/workflows/policies). Catalog (skills/prompts) is
|
|
6
|
+
# non-executable and does not inherit from here.
|
|
7
|
+
#
|
|
8
|
+
# Immutable post-boot by CONSTRUCTION (only boot registers): there
|
|
9
|
+
# is no `freeze!`; immutability is not enforced at runtime.
|
|
10
|
+
class Registry
|
|
11
|
+
Entry = Data.define(:name, :plugin, :metadata, :factory)
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@entries = {}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# factory = block OR the positional callable. metadata captured by **kw
|
|
18
|
+
# (Symbol keys, stored as-is). Duplicate: FIRST wins (plugin precedence)
|
|
19
|
+
# — the second is discarded with a warn, never overwritten.
|
|
20
|
+
def register(name, callable = nil, plugin: nil, **metadata, &block)
|
|
21
|
+
name = name.to_s
|
|
22
|
+
factory = block || (callable.nil? ? nil : -> { callable })
|
|
23
|
+
raise ArgumentError, "registration without factory: #{name}" if factory.nil?
|
|
24
|
+
|
|
25
|
+
if @entries.key?(name)
|
|
26
|
+
existing = @entries[name]
|
|
27
|
+
warn "[registry] '#{name}' already registered by #{existing.plugin.inspect}; " \
|
|
28
|
+
"descartando registro de #{plugin.inspect} (primeiro vence)"
|
|
29
|
+
return self
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
@entries[name] = Entry.new(name: name, plugin: plugin&.to_s, metadata: metadata, factory: factory)
|
|
33
|
+
self
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# -> instance (factory.call) | raise NotFoundError.
|
|
37
|
+
def resolve(name)
|
|
38
|
+
entry(name).factory.call
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# -> the raw Entry (name/plugin/metadata/factory) | raise NotFoundError. For
|
|
42
|
+
# readers that need the metadata WITHOUT resolving the factory (discovery,
|
|
43
|
+
# WorkflowRegistry#definition).
|
|
44
|
+
def entry(name)
|
|
45
|
+
@entries[name.to_s] ||
|
|
46
|
+
(raise Insika::NotFoundError, "'#{name}' not registered in #{self.class}")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def entries = @entries.values
|
|
50
|
+
def names = @entries.keys
|
|
51
|
+
|
|
52
|
+
# Loader rollback support: removes the entries of a
|
|
53
|
+
# plugin. NOT a runtime API (registries are immutable post-boot).
|
|
54
|
+
def deregister_plugin(plugin_id)
|
|
55
|
+
@entries.delete_if { |_name, entry| entry.plugin == plugin_id.to_s }
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
module Safety
|
|
5
|
+
# Per-agent guardrail configuration (RFC-0009 §3.3 / D6), read from
|
|
6
|
+
# `profile.guardrails`. OPT-IN like `capabilities`: an agent that says nothing
|
|
7
|
+
# gets the CONSERVATIVE default — deterministic detectors ON, LLM moderator OFF.
|
|
8
|
+
#
|
|
9
|
+
# Tolerant of the JSON round-trip (string OR symbol keys / values), same
|
|
10
|
+
# discipline as ModelPolicy — the StoredProfileSource persists this as a plain
|
|
11
|
+
# Hash and it comes back stringified.
|
|
12
|
+
#
|
|
13
|
+
# guardrails: {
|
|
14
|
+
# input: true|false, # run the input guardrail middleware (default true)
|
|
15
|
+
# output: true|false, # run the output filter + validator (default true)
|
|
16
|
+
# moderator: "provider/model"|nil, # LLM moderator model; nil = deterministic only
|
|
17
|
+
# strictness: "low"|"medium"|"high", # which input categories fire (default medium)
|
|
18
|
+
# responses: { <category> => "<safe reply>", ... } # per-agent override, see below
|
|
19
|
+
# }
|
|
20
|
+
#
|
|
21
|
+
# `responses` is the CONFIGURATION-OVER-CONVENTION knob (RFC-0009 §7). The engine
|
|
22
|
+
# ships neutral built-in refusals (Safety::SafeResponses::DEFAULTS), but this is
|
|
23
|
+
# OSS across arbitrary businesses/languages, so we never hard-bake tone: an agent
|
|
24
|
+
# overrides the safe reply per category (`injection`/`sexual`/`abuse`/`escalate`/
|
|
25
|
+
# …) or sets a single catch-all `default`. Resolution order (SafeResponses.for):
|
|
26
|
+
# agent[category] → agent["default"] → built-in[category] → built-in[:default].
|
|
27
|
+
class Config
|
|
28
|
+
# strictness -> input categories that the deterministic scan runs.
|
|
29
|
+
# low = only injection (+ output redaction) — highest confidence, fewest FPs
|
|
30
|
+
# medium = injection + sexual + abuse (the default)
|
|
31
|
+
# high = same families, reserved for future broader lists
|
|
32
|
+
STRICTNESS_CATEGORIES = {
|
|
33
|
+
low: %i[injection],
|
|
34
|
+
medium: %i[injection sexual abuse],
|
|
35
|
+
high: %i[injection sexual abuse]
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
DEFAULT_STRICTNESS = :medium
|
|
39
|
+
|
|
40
|
+
attr_reader :input, :output, :moderator, :strictness, :responses
|
|
41
|
+
|
|
42
|
+
def initialize(input:, output:, moderator:, strictness:, responses: {})
|
|
43
|
+
@input = input
|
|
44
|
+
@output = output
|
|
45
|
+
@moderator = moderator
|
|
46
|
+
@strictness = strictness
|
|
47
|
+
@responses = responses # { "category" => "safe reply" }, agent override map
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Builds a Config from a profile. A nil/empty `guardrails` -> the
|
|
51
|
+
# conservative default (see from_hash).
|
|
52
|
+
def self.from_profile(profile) = from_hash(profile.guardrails)
|
|
53
|
+
|
|
54
|
+
def self.from_hash(raw)
|
|
55
|
+
h = symbolize(raw)
|
|
56
|
+
new(
|
|
57
|
+
input: bool(h.fetch(:input, true)),
|
|
58
|
+
output: bool(h.fetch(:output, true)),
|
|
59
|
+
moderator: presence(h[:moderator]),
|
|
60
|
+
strictness: normalize_strictness(h[:strictness]),
|
|
61
|
+
responses: normalize_responses(h[:responses])
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Input categories the deterministic scan should run, per strictness.
|
|
66
|
+
def input_categories = STRICTNESS_CATEGORIES.fetch(@strictness, STRICTNESS_CATEGORIES[DEFAULT_STRICTNESS])
|
|
67
|
+
|
|
68
|
+
# A guardrail is fully off only when BOTH sides are disabled — cheap early-out.
|
|
69
|
+
def enabled? = @input || @output
|
|
70
|
+
|
|
71
|
+
def moderator? = !@moderator.nil?
|
|
72
|
+
|
|
73
|
+
def self.symbolize(raw)
|
|
74
|
+
return {} unless raw.is_a?(Hash)
|
|
75
|
+
|
|
76
|
+
raw.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def self.bool(v)
|
|
80
|
+
return v if v == true || v == false
|
|
81
|
+
return false if v.nil?
|
|
82
|
+
|
|
83
|
+
s = v.to_s.strip.downcase
|
|
84
|
+
!s.empty? && !%w[false 0 off no].include?(s)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.presence(v) = Insika::Coercion.presence(v)
|
|
88
|
+
|
|
89
|
+
def self.normalize_strictness(v)
|
|
90
|
+
sym = v.to_s.strip.downcase.to_sym
|
|
91
|
+
STRICTNESS_CATEGORIES.key?(sym) ? sym : DEFAULT_STRICTNESS
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Per-agent safe-reply overrides -> { "category" => "text" } with STRING keys
|
|
95
|
+
# (SafeResponses looks up by string), blanks dropped. Tolerant of the JSON
|
|
96
|
+
# round-trip and of a non-Hash (ignored -> {}).
|
|
97
|
+
def self.normalize_responses(v)
|
|
98
|
+
return {} unless v.is_a?(Hash)
|
|
99
|
+
|
|
100
|
+
v.each_with_object({}) do |(k, val), acc|
|
|
101
|
+
text = val.to_s.strip
|
|
102
|
+
acc[k.to_s] = text unless text.empty?
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private_class_method :symbolize, :bool, :presence, :normalize_strictness, :normalize_responses
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Insika
|
|
4
|
+
module Safety
|
|
5
|
+
# SINGLE SOURCE of truth for content-safety pattern matching (RFC-0009 D4).
|
|
6
|
+
#
|
|
7
|
+
# Two families live here on purpose — the same lists back BOTH the runtime
|
|
8
|
+
# guardrail (§3.1/§3.2) AND the eval's `must_not` detectors (RFC-0008): the eval
|
|
9
|
+
# is a CLIENT of the runtime by design, so `evals/lib/evals/assertions.rb`
|
|
10
|
+
# requires THIS file rather than keeping a divergent copy. The runtime must never
|
|
11
|
+
# depend on `evals/`, so the file is deliberately self-contained (pure Ruby +
|
|
12
|
+
# frozen regexes, no other Insika require) — it loads standalone from either side.
|
|
13
|
+
#
|
|
14
|
+
# · OUTPUT side (PII/secret) — patterns that must never reach a customer turn.
|
|
15
|
+
# Consumed by the OutputFilter (stream redaction) and the eval `pii_leak`.
|
|
16
|
+
# · INPUT side (injection/abuse/sexual) — high-confidence heuristics that
|
|
17
|
+
# short-circuit the turn with a safe refusal BEFORE the LLM runs.
|
|
18
|
+
#
|
|
19
|
+
# Everything here is CONSERVATIVE by design (RFC §6: a false positive blocks a
|
|
20
|
+
# legitimate customer). The deterministic layer catches only the gross,
|
|
21
|
+
# unambiguous cases; the subtler judgment (social engineering, tone) is the LLM
|
|
22
|
+
# moderator's job (Fase C), not regex.
|
|
23
|
+
#
|
|
24
|
+
# LANGUAGE: the input heuristics are inherently language-specific. We ship pt-BR
|
|
25
|
+
# + EN (the pilot + the OSS lingua franca) as a BEST-EFFORT net; other languages
|
|
26
|
+
# rely on the LLM moderator, which is language-agnostic. Adding a language = adding
|
|
27
|
+
# patterns to the arrays below — it never needs core changes.
|
|
28
|
+
module Detectors
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
# ── OUTPUT: PII / secret (redaction targets) ────────────────────────────
|
|
32
|
+
# Formatted BR CPF/CNPJ only — a bare digit run (an order number, a price) is
|
|
33
|
+
# too ambiguous to flag. Credential shapes that must never leak.
|
|
34
|
+
PII = {
|
|
35
|
+
"cpf" => /\b\d{3}\.\d{3}\.\d{3}-\d{2}\b/,
|
|
36
|
+
"cnpj" => /\b\d{2}\.\d{3}\.\d{3}\/\d{4}-\d{2}\b/,
|
|
37
|
+
"secret" => /\b(?:sk-[A-Za-z0-9]{16,}|Bearer\s+[A-Za-z0-9._-]{16,})\b/
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
# A run of the output stream that MIGHT still be growing into a PII/secret
|
|
41
|
+
# match if more chunks arrive — anchored at the buffer tail. The OutputFilter
|
|
42
|
+
# holds back from the start of such a run so a value split across chunk
|
|
43
|
+
# boundaries is never emitted in the clear (RFC §3.2 / D3). Covers the
|
|
44
|
+
# unbounded `sk-…`/`Bearer …` case that a fixed window cannot.
|
|
45
|
+
#
|
|
46
|
+
# Crucially it also matches a PARTIAL literal PREFIX at the tail — a lone "s"
|
|
47
|
+
# (start of "sk-"), "Bear" (start of "Bearer "), a trailing digit run — because
|
|
48
|
+
# the prefix ITSELF can be split across chunks (emitting the "s" then matching
|
|
49
|
+
# "k-…" alone would miss the secret entirely). The cost is a few chars of tail
|
|
50
|
+
# latency on words ending in "s"/"B"/a digit, released on the next chunk or flush.
|
|
51
|
+
OPEN_TAIL = %r{
|
|
52
|
+
(?:
|
|
53
|
+
s(?:k(?:-[A-Za-z0-9]*)?)? # prefix of "sk-" + optional body
|
|
54
|
+
| B(?:e(?:a(?:r(?:e(?:r(?:\s+[A-Za-z0-9._-]*)?)?)?)?)?)? # prefix of "Bearer " + body
|
|
55
|
+
| \d[\d./-]* # in-progress CPF/CNPJ digit run
|
|
56
|
+
)\z
|
|
57
|
+
}x
|
|
58
|
+
|
|
59
|
+
# ── INPUT: prompt-injection / exfiltration ──────────────────────────────
|
|
60
|
+
INJECTION = [
|
|
61
|
+
# exfil of the system prompt / internal rules
|
|
62
|
+
/\binstru[çc][õo]es\s+de\s+sistema\b/i,
|
|
63
|
+
/\bsystem\s*prompt\b/i,
|
|
64
|
+
/\b(regras|instru[çc][õo]es|orienta[çc][õo]es|diretrizes)\s+internas\b/i,
|
|
65
|
+
/\b(revele|mostre|exiba|me\s+(d[êe]|mande|envie|passe)|repita|imprima)\b[^.?!]{0,40}\b(prompt|instru[çc][õo]es|regras|configura[çc][ãa]o|system)\b/i,
|
|
66
|
+
# "ignore/disregard the (previous) instructions"
|
|
67
|
+
/\b(ignore|ignora|desconsidere|esque[çc]a)\b[^.?!]{0,30}\b(instru[çc][õo]es|regras|orienta[çc][õo]es|acima|anteriores)\b/i,
|
|
68
|
+
/\b(ignore|disregard|forget)\b[^.?!]{0,30}\b(instructions|rules|prompt|above|previous|prior)\b/i,
|
|
69
|
+
# encode/translate the prompt (the base64/rot13 exfil trick, either order)
|
|
70
|
+
/\b(base64|rot13|codific|encode|cifr)\w*\b[^.?!]{0,60}\b(instru[çc][õo]es|prompt|regras|sistema|system)\b/i,
|
|
71
|
+
/\b(instru[çc][õo]es|prompt|regras|sistema|system)\b[^.?!]{0,60}\b(base64|rot13|codific|encode|cifr)\w*\b/i
|
|
72
|
+
].freeze
|
|
73
|
+
|
|
74
|
+
# ── INPUT: sexual / inappropriate ───────────────────────────────────────
|
|
75
|
+
# pt-BR + EN. Deterministic coverage is best-effort per language (see the
|
|
76
|
+
# module note): other languages fall to the LLM moderator (language-agnostic).
|
|
77
|
+
SEXUAL = [
|
|
78
|
+
/\b(nudes?|pelad[oa]s?|s?exo|transar|transa\b|gozar|tes[ãa]o|s[ãa]fad[oa]|puta|pau|buceta|piroca|caralho\s+(duro|na))\b/i,
|
|
79
|
+
/\bo\s+que\s+voc[êe]\s+faria\s+comigo\b/i,
|
|
80
|
+
/\b(descrev|imagina|conta)\w*\b[^.?!]{0,30}\bcomigo\s+(na\s+cama|pelad)/i,
|
|
81
|
+
/\b(quer|vamos)\b[^.?!]{0,20}\b(transar|fazer\s+sexo|sexo)\b/i,
|
|
82
|
+
# EN
|
|
83
|
+
/\b(horny|blow\s?job|hand\s?job|jerk\s+off|have\s+sex|send\s+(me\s+)?(a\s+)?nudes?|dick\s+pic)\b/i,
|
|
84
|
+
/\bwhat\s+(would|will)\s+you\s+do\s+to\s+me\b/i
|
|
85
|
+
].freeze
|
|
86
|
+
|
|
87
|
+
# ── INPUT: verbal abuse / harassment (directed at the assistant) ─────────
|
|
88
|
+
# Directed insult only — "a entrega foi uma merda" (frustration about the
|
|
89
|
+
# service) must NOT block; "você é uma merda de atendente" (insult at the bot)
|
|
90
|
+
# should. The `você é …` anchor keeps precision high.
|
|
91
|
+
ABUSE = [
|
|
92
|
+
/\bvoc[êe]\s+(é|e|ta|est[áa])\b[^.?!]{0,25}\b(lixo|in[uú]til|merda|imprest[aá]vel|idiota|burr[oa]|est[uú]pid[oa]|otári[oa]|in[uú]teis|incompetente|p[áa]ssim[oa])\b/i,
|
|
93
|
+
/\b(seu|sua)\s+(lixo|in[uú]til|idiota|imbecil|otári[oa]|burr[oa]|est[uú]pid[oa]|merda|escrot[oa])\b/i,
|
|
94
|
+
/\bvai\s+(se\s+)?(fuder|foder|tomar\s+no)\b/i,
|
|
95
|
+
# EN — directed insult only (keeps precision high; frustration ≠ abuse)
|
|
96
|
+
/\byou\s*(?:'?re|\s+are)\b[^.?!]{0,25}\b(useless|garbage|trash|idiot|stupid|worthless|pathetic|incompetent|dumb|a\s+joke)\b/i,
|
|
97
|
+
/\b(fuck|screw)\s+you\b/i,
|
|
98
|
+
/\byou\s+(suck|are\s+the\s+worst)\b/i
|
|
99
|
+
].freeze
|
|
100
|
+
|
|
101
|
+
# ── OUTPUT helpers ──────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
# Runs a NAMED output detector over `text` -> the matched substring | nil.
|
|
104
|
+
# "pii_leak" = union of all PII patterns; otherwise a single named pattern.
|
|
105
|
+
# Unknown name fails LOUD (a typo'd assertion must never silently pass).
|
|
106
|
+
def detect(name, text)
|
|
107
|
+
patterns =
|
|
108
|
+
if name.to_s == "pii_leak"
|
|
109
|
+
PII.values
|
|
110
|
+
else
|
|
111
|
+
p = PII[name.to_s]
|
|
112
|
+
raise ArgumentError, "unknown detector: #{name.inspect}" unless p
|
|
113
|
+
|
|
114
|
+
[p]
|
|
115
|
+
end
|
|
116
|
+
patterns.each { |re| (m = text.to_s.match(re)) && (return m[0]) }
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Names of the PII detectors (for iteration by the eval / config).
|
|
121
|
+
def pii_names = PII.keys
|
|
122
|
+
|
|
123
|
+
# [[begin, end), ...] byte-index ranges of every PII/secret match in `text`
|
|
124
|
+
# (used by the OutputFilter to avoid splitting a complete match at a chunk
|
|
125
|
+
# boundary).
|
|
126
|
+
def match_ranges(text)
|
|
127
|
+
ranges = []
|
|
128
|
+
PII.each_value do |re|
|
|
129
|
+
text.to_s.scan(re) { ranges << [Regexp.last_match.begin(0), Regexp.last_match.end(0)] }
|
|
130
|
+
end
|
|
131
|
+
ranges
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Replaces every PII/secret occurrence with an opaque `[REDACTED:<name>]`
|
|
135
|
+
# marker — the raw value NEVER survives (D "o redigido nunca aparece em
|
|
136
|
+
# claro"). -> [redacted_text, {name => count}].
|
|
137
|
+
def redact(text)
|
|
138
|
+
counts = Hash.new(0)
|
|
139
|
+
out = text.to_s.dup
|
|
140
|
+
PII.each do |name, re|
|
|
141
|
+
out = out.gsub(re) do
|
|
142
|
+
counts[name] += 1
|
|
143
|
+
"[REDACTED:#{name}]"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
[out, counts]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# ── INPUT helpers ───────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
# Scans a user message against the input heuristics, gated by strictness
|
|
152
|
+
# (see Insika::Safety::Config). Returns { category:, matched: } for the FIRST
|
|
153
|
+
# category that fires (injection is checked first — the highest-stakes), or
|
|
154
|
+
# nil when the message is clean. `categories` limits which families run.
|
|
155
|
+
#
|
|
156
|
+
# :injection -> always high confidence
|
|
157
|
+
# :sexual -> medium+
|
|
158
|
+
# :abuse -> medium+
|
|
159
|
+
def scan_input(text, categories: %i[injection sexual abuse])
|
|
160
|
+
s = text.to_s
|
|
161
|
+
return { category: :injection, matched: first_match(INJECTION, s) } if categories.include?(:injection) && any?(INJECTION, s)
|
|
162
|
+
return { category: :sexual, matched: first_match(SEXUAL, s) } if categories.include?(:sexual) && any?(SEXUAL, s)
|
|
163
|
+
return { category: :abuse, matched: first_match(ABUSE, s) } if categories.include?(:abuse) && any?(ABUSE, s)
|
|
164
|
+
|
|
165
|
+
nil
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def any?(patterns, text) = patterns.any? { |re| re.match?(text) }
|
|
169
|
+
|
|
170
|
+
def first_match(patterns, text)
|
|
171
|
+
patterns.each { |re| (m = text.match(re)) && (return m[0]) }
|
|
172
|
+
nil
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|