visiq 0.1.2-aarch64-linux → 0.1.4-aarch64-linux

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9e978c98b7d7abd26c143537d76eaa6bce5637d1180aeed084b0f31cc262ed02
4
- data.tar.gz: 36a5bb504f076b8a075475917a0e153e34416ea42d6d294cd9035c60fb5f3a58
3
+ metadata.gz: 53677ad63fab15988934af48c247fb5e824306014ceb3cb3d569a9f46d7aae99
4
+ data.tar.gz: 6416f7204f3512340aa8b25b016cc2d1aa066fe7e37ab5f9eb89eb2013559a53
5
5
  SHA512:
6
- metadata.gz: 7485991f5b8a6d99879bd1b80023cfa0595c2f6af7ccf636f0a00c522ba7a9287a3c3bb89490fbf1c2641c47aa81598f26ed190cf1d9a7af97bfdc1edbe553fc
7
- data.tar.gz: 2a22cf69137b715a833a05d7215e8ad853594b448287bb9ef59f88f1d7881ac3d51dbf9b24b0ed9590821612a2e35d14b8bb82fff88bf058adf4b96c5ac16796
6
+ metadata.gz: '0904b07c6606d3a38183737a9a976234d56ea96656e11ac5303825518ad3fa29b7c0a659a7fbe589eec23f5efde383eb907e9b68b6a6469ee8e78de094f5984a'
7
+ data.tar.gz: 42ee14d74c482640ef290799bebf4342bb0b46b843ded836a13f6c05610175cb89b2978efec6d9812c0d8a3b47022dc49de1089ff71da50508d50209c171cac6
data/README.md CHANGED
@@ -7,7 +7,8 @@ in-process by the compiled VisIQ core that every VisIQ SDK shares (the same engi
7
7
  behind `@visiq/harness`), bound to Ruby through the stdlib `Fiddle` FFI. The engine
8
8
  ships as **compiled machine code** (closed); the Ruby is thin FFI plumbing only.
9
9
 
10
- - **Local & fast** — every decision is in-process, no network.
10
+ - **Local & fast**: every decision is in-process. `Visiq::Governor` fetches the
11
+ agent's rules from the control plane and keeps them current.
11
12
  - **Fail-open by default (G001 rescope, owner 2026-07-15)** — a harness-internal
12
13
  failure (the core can't load, an FFI error) returns a loud fail-open permit;
13
14
  set `VISIQ_FAIL_MODE=closed` to block instead. Malformed input and explicit
@@ -25,7 +26,46 @@ Native, per-platform "fat" gems are published for macOS (arm64, x86_64) and Linu
25
26
  (x86_64, aarch64) — `gem install` selects the one matching your platform and it
26
27
  bundles the compiled core. **Ruby ≥ 3.0.**
27
28
 
28
- ## Usage
29
+ ## Usage (recommended): the self-refreshing `Visiq::Governor`
30
+
31
+ ```ruby
32
+ require "visiq"
33
+
34
+ # One per agent, kept for the life of the process. Reads VISIQ_API_KEY (and
35
+ # VISIQ_ENDPOINT for on-premises) from the environment when not passed here.
36
+ governor = Visiq::Governor.new(agent_id: "agent-1")
37
+
38
+ d = governor.gate_action(tool_name: "wire_transfer", args: { "amount" => 999 })
39
+ block_the_call unless d["allowed"]
40
+ r = governor.gate_retrieval(resource_metadata: { "classification" => "ssn" })
41
+ ```
42
+
43
+ The `Governor` fetches `GET /rules/bundle?agent_id=...` itself, fetches it again
44
+ before a decision once it is more than 5 seconds old (sending `If-None-Match`, so
45
+ an unchanged bundle costs a 304), and decides in-process against it. So a rule
46
+ change, or the agent being put in Agent Jail, reaches the next call. A failed or
47
+ timed-out fetch (2.5 s) keeps the rules already held, so a jail stays in force
48
+ through a control-plane outage. With no rules yet (first fetch failed, or no API
49
+ key configured) calls are permitted with a loud `[VisIQ] FAIL-OPEN (no-bundle)`
50
+ stderr line; `VISIQ_FAIL_MODE=closed` (or `fail_mode: "closed"`) denies them
51
+ instead. Refresh is lazy, on the calling thread, one fetch at a time; there is
52
+ no background thread.
53
+
54
+ | Option | Environment variable | Default |
55
+ |---|---|---|
56
+ | `endpoint:` | `VISIQ_ENDPOINT`, then `VISIQ_BASE_URL` | `https://api.visiqlabs.com` when an API key is set |
57
+ | `api_key:` | `VISIQ_API_KEY`, then `VISIQ_ALLOW_API_KEY` | none |
58
+ | `agent_id:` | `VISIQ_AGENT_ID` | `agent` |
59
+ | `fail_mode:` | `VISIQ_FAIL_MODE` | `open` |
60
+
61
+ `governor.decide(event)` decides any event; `governor.bundle` returns the
62
+ (frozen) bundle in use.
63
+
64
+ ## Usage: if you fetch the bundle yourself
65
+
66
+ The module functions decide against a bundle you pass in and make no network
67
+ calls. Fetch the bundle again at least every few seconds: a kill switch such as
68
+ Agent Jail reaches the agent only through a fresh bundle.
29
69
 
30
70
  ```ruby
31
71
  require "visiq"
@@ -0,0 +1,261 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module Visiq
8
+ # The self-refreshing VisIQ client for Ruby: it fetches the agent's rule bundle
9
+ # from the control plane itself, keeps it fresh, and decides every call through
10
+ # the same compiled core as Visiq.gate_action.
11
+ #
12
+ # Why it exists. The module functions (Visiq.gate_action and friends) decide
13
+ # against whatever bundle the caller passes in. When an operator puts an agent
14
+ # in Agent Jail, GET /rules/bundle starts serving a terminal bundle
15
+ # ("shutdown": true) and the core denies every facet for it, but a program that
16
+ # fetched once at startup never sees that bundle, so the jailed agent keeps
17
+ # acting. A Governor re-fetches on its own, so the jail reaches the next call.
18
+ #
19
+ # The contract (mirrors the Python Governor and the TypeScript UnifiedRuntime):
20
+ #
21
+ # - Config: explicit keyword options first, then the environment. Endpoint from
22
+ # VISIQ_ENDPOINT or VISIQ_BASE_URL (trailing slash stripped); with an API key
23
+ # and no endpoint, the SaaS default DEFAULT_SAAS_ENDPOINT. API key from
24
+ # VISIQ_API_KEY then VISIQ_ALLOW_API_KEY. Agent id from VISIQ_AGENT_ID, else
25
+ # "agent". Fail mode from VISIQ_FAIL_MODE, else "open".
26
+ # - Request: GET {endpoint}/rules/bundle?agent_id=... with Authorization: Bearer,
27
+ # X-VisIQ-SDK: <version> (bare), X-VisIQ-Dialect: 1, and If-None-Match once an
28
+ # ETag is held. A 304 keeps the held bundle. Timeout 2.5 s (connect, and each
29
+ # read).
30
+ # - Refresh: lazy, on the calling thread, before a decision, once the last fetch
31
+ # attempt is older than the refresh interval (5 s). Single flight: one caller
32
+ # fetches; while a bundle is held the others decide on it without waiting, and
33
+ # with no bundle yet they wait for that one fetch and do not start their own.
34
+ # No background thread: a lazy refresh is safe here (the held bundle is one
35
+ # frozen snapshot swapped by a single assignment), and a thread would keep
36
+ # fetching for an agent that is idle and outlive forks the host app makes.
37
+ # - Failure: a failed, timed-out or malformed fetch keeps the last good bundle
38
+ # whatever its age. A held shutdown:true bundle never expires, so a jail stays
39
+ # in force through a control-plane outage and is lifted only by a successful
40
+ # fetch of a non-shutdown bundle.
41
+ # - No bundle (never fetched one, or not configured): fail OPEN by default with
42
+ # one loud "[VisIQ] FAIL-OPEN (no-bundle): ..." stderr line and a non-enforced
43
+ # permit; under fail mode "closed", an enforced deny. A policy deny from a
44
+ # loaded bundle (the jail is one) always applies (G001).
45
+ # - Not configured (no endpoint and key): never touches the network; every
46
+ # decision takes the no-bundle path above.
47
+ #
48
+ # governor = Visiq::Governor.new(agent_id: "support-bot")
49
+ # decision = governor.gate_action(tool_name: "wire_transfer", args: { "amount" => 999 })
50
+ # block_the_call unless decision["allowed"]
51
+ class Governor
52
+ # The managed SaaS control plane, used only when an API key is set and no endpoint is.
53
+ DEFAULT_SAAS_ENDPOINT = "https://api.visiqlabs.com"
54
+ # Seconds the held bundle may age before the next decision re-fetches it.
55
+ DEFAULT_REFRESH_INTERVAL = 5.0
56
+ # Seconds allowed to connect, and for each read, of the bundle request.
57
+ DEFAULT_TIMEOUT = 2.5
58
+ # The wire-contract capability integer sent as X-VisIQ-Dialect (the same value
59
+ # as the Python and TypeScript SDK_DIALECT).
60
+ SDK_DIALECT = 1
61
+ SDK_VERSION_HEADER = "X-VisIQ-SDK"
62
+ SDK_DIALECT_HEADER = "X-VisIQ-Dialect"
63
+ # The X-VisIQ-SDK value this SDK sends: the bare package version, as the
64
+ # Python and TypeScript SDKs send it. The backend reads the header against
65
+ # /^[A-Za-z0-9._+-]+$/ and treats anything else (a "ruby/" prefix included)
66
+ # as absent.
67
+ SDK_HEADER_VALUE = Visiq::VERSION
68
+
69
+ attr_reader :endpoint, :agent_id, :fail_mode, :refresh_interval, :timeout
70
+
71
+ # Every option left nil falls back to the environment. `env:` defaults to ENV
72
+ # and exists so a caller (or a test) can resolve from another source.
73
+ def initialize(endpoint: nil, api_key: nil, agent_id: nil, fail_mode: nil,
74
+ refresh_interval: nil, timeout: nil, env: ENV)
75
+ ep = first_present(endpoint, env["VISIQ_ENDPOINT"], env["VISIQ_BASE_URL"])
76
+ key = first_present(api_key, env["VISIQ_API_KEY"], env["VISIQ_ALLOW_API_KEY"])
77
+ if ep
78
+ ep = ep.sub(%r{/+\z}, "")
79
+ elsif key
80
+ ep = DEFAULT_SAAS_ENDPOINT
81
+ end
82
+ @endpoint = ep
83
+ @api_key = key
84
+ @agent_id = first_present(agent_id, env["VISIQ_AGENT_ID"]) || "agent"
85
+ @fail_mode = Visiq.normalize_fail_mode(fail_mode) || Visiq.normalize_fail_mode(env["VISIQ_FAIL_MODE"]) || "open"
86
+ @refresh_interval = Float(refresh_interval || DEFAULT_REFRESH_INTERVAL)
87
+ @timeout = Float(timeout || DEFAULT_TIMEOUT)
88
+ raise ArgumentError, "refresh_interval must be positive" unless @refresh_interval.positive?
89
+ raise ArgumentError, "timeout must be positive" unless @timeout.positive?
90
+
91
+ @fetch_mutex = Mutex.new
92
+ @held = nil # [frozen bundle Hash, etag String or nil], replaced whole
93
+ @last_attempt = nil # monotonic seconds of the last fetch attempt
94
+ end
95
+
96
+ # True when an endpoint and an API key are both present, so it can fetch.
97
+ def configured?
98
+ !@endpoint.nil? && !@api_key.nil?
99
+ end
100
+
101
+ # The (deep-frozen) bundle decisions are made against, or nil before the first
102
+ # successful fetch. Safe to pass on to Visiq.gate_action.
103
+ def bundle
104
+ held = @held
105
+ held && held[0]
106
+ end
107
+
108
+ # Decide one tool/action call for this governor's agent. Same decision shape
109
+ # as Visiq.gate_action: check ["allowed"]; on "mask" apply
110
+ # ["action"]["argRedactionRules"] to the args before running the tool.
111
+ def gate_action(tool_name:, args: nil, target_resource: nil, normalized: nil)
112
+ event = Visiq.action_event(tool_name: tool_name, args: args, agent_id: @agent_id,
113
+ target_resource: target_resource, normalized: normalized)
114
+ decide_event(event, "tool #{tool_name.inspect}")
115
+ end
116
+
117
+ # Decide one retrieval for this governor's agent. Same decision shape as
118
+ # Visiq.gate_retrieval: inspect ["retrieval"]["action"].
119
+ def gate_retrieval(resource_metadata: nil, resource_type: "document", query: nil)
120
+ event = Visiq.retrieval_event(resource_metadata: resource_metadata, resource_type: resource_type,
121
+ agent_id: @agent_id, query: query)
122
+ decide_event(event, "retrieval")
123
+ end
124
+
125
+ # Decide any governed event Hash against the held bundle (an "agentId" is
126
+ # added when the event carries none). Same shape as Visiq.decide.
127
+ def decide(event)
128
+ ev = event
129
+ ev = event.merge("agentId" => @agent_id) if event.is_a?(Hash) && event["agentId"].nil?
130
+ decide_event(ev, "event")
131
+ end
132
+
133
+ private
134
+
135
+ def decide_event(event, subject)
136
+ ensure_fresh
137
+ held = @held # ONE consistent snapshot for this decision
138
+ return no_bundle_decision(event, subject) if held.nil?
139
+
140
+ Visiq.decide_with_fail_mode(event, held[0], @fail_mode)
141
+ end
142
+
143
+ def now
144
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
145
+ end
146
+
147
+ def recently_attempted?
148
+ last = @last_attempt
149
+ !last.nil? && now - last < @refresh_interval
150
+ end
151
+
152
+ # Lazy, single-flight refresh on the calling thread.
153
+ def ensure_fresh
154
+ return if !configured? || recently_attempted?
155
+
156
+ if @held
157
+ # A bundle is held: if another caller is already fetching, decide on the
158
+ # held bundle now rather than queue behind (or repeat) its fetch.
159
+ return unless @fetch_mutex.try_lock
160
+ else
161
+ # No bundle yet: wait for the in-flight fetch instead of starting one.
162
+ @fetch_mutex.lock
163
+ end
164
+ begin
165
+ return if recently_attempted? # a caller ahead of us just fetched
166
+
167
+ begin
168
+ fetch_once
169
+ ensure
170
+ # Success or failure, the interval restarts here, so an outage costs one
171
+ # timed-out fetch per interval, not one per call.
172
+ @last_attempt = now
173
+ end
174
+ ensure
175
+ @fetch_mutex.unlock
176
+ end
177
+ end
178
+
179
+ # One GET /rules/bundle. Any failure keeps the held bundle.
180
+ def fetch_once
181
+ current = @held
182
+ query = URI.encode_www_form_component(@agent_id).gsub("+", "%20")
183
+ uri = URI("#{@endpoint}/rules/bundle?agent_id=#{query}")
184
+ http = Net::HTTP.new(uri.host, uri.port)
185
+ http.use_ssl = uri.scheme == "https"
186
+ http.open_timeout = @timeout
187
+ http.read_timeout = @timeout
188
+ http.write_timeout = @timeout if http.respond_to?(:write_timeout=)
189
+ http.ssl_timeout = @timeout if http.use_ssl?
190
+ req = Net::HTTP::Get.new(uri)
191
+ req["Accept"] = "application/json"
192
+ req["Authorization"] = "Bearer #{@api_key}"
193
+ req[SDK_VERSION_HEADER] = SDK_HEADER_VALUE
194
+ req[SDK_DIALECT_HEADER] = SDK_DIALECT.to_s
195
+ req["If-None-Match"] = current[1] if current && current[1]
196
+
197
+ res = http.start { |h| h.request(req) }
198
+ code = res.code.to_i
199
+ return if code == 304 && current # unchanged: keep the held bundle
200
+ return fetch_failed("HTTP #{code}") unless code >= 200 && code < 300
201
+
202
+ body = JSON.parse(res.body.to_s)
203
+ return fetch_failed("the bundle response was not a JSON object") unless body.is_a?(Hash)
204
+
205
+ @held = [deep_freeze(body), res["ETag"]].freeze
206
+ rescue StandardError => e
207
+ fetch_failed("#{e.class}: #{e.message}")
208
+ end
209
+
210
+ def fetch_failed(detail)
211
+ if @held.nil?
212
+ warn "[VisIQ] bundle fetch failed (ungoverned until loaded): #{detail}"
213
+ else
214
+ warn "[VisIQ] bundle refresh failed (keeping the last good rules): #{detail}"
215
+ end
216
+ nil
217
+ end
218
+
219
+ def no_bundle_decision(event, subject)
220
+ ops = event.is_a?(Hash) ? Array(event["operations"]) : []
221
+ has_action = ops.include?("action") || ops.include?("delegation")
222
+ has_retrieval = ops.include?("retrieval")
223
+ closed = @fail_mode == "closed"
224
+ unless closed
225
+ warn "[VisIQ] FAIL-OPEN (no-bundle): governance unavailable for #{subject}; " \
226
+ "proceeding UNGOVERNED. Set VISIQ_FAIL_MODE=closed to block instead."
227
+ end
228
+ {
229
+ "decision" => closed ? "deny" : "permit",
230
+ "allowed" => !closed,
231
+ "reason" => if closed
232
+ "no rules bundle loaded (fail-closed, VISIQ_FAIL_MODE=closed)"
233
+ else
234
+ "fail-open: no rules bundle loaded; proceeding UNGOVERNED " \
235
+ "(set VISIQ_FAIL_MODE=closed to block instead)"
236
+ end,
237
+ "ruleId" => nil,
238
+ "ruleCode" => nil,
239
+ "enforced" => closed,
240
+ "action" => if has_action
241
+ { "decision" => closed ? "deny" : "permit", "allowed" => !closed }
242
+ else
243
+ { "decision" => nil, "allowed" => true }
244
+ end,
245
+ "retrieval" => { "action" => has_retrieval ? (closed ? "deny" : "allow") : nil }
246
+ }
247
+ end
248
+
249
+ def first_present(*values)
250
+ values.find { |v| !v.nil? && !v.to_s.empty? }&.to_s
251
+ end
252
+
253
+ def deep_freeze(obj)
254
+ case obj
255
+ when Hash then obj.each_value { |v| deep_freeze(v) }
256
+ when Array then obj.each { |v| deep_freeze(v) }
257
+ end
258
+ obj.freeze
259
+ end
260
+ end
261
+ end
data/lib/visiq.rb CHANGED
@@ -43,7 +43,12 @@ module Visiq
43
43
  # Semantic-Kernel adapter defect (see its Cargo.toml). That hold gates the PyPI
44
44
  # wheel, which ships the core's own version; Ruby and Java version independently
45
45
  # and are not implicated. Bumping here does not resume the Python release.
46
- VERSION = "0.1.2"
46
+ #
47
+ # 0.1.3 -> 0.1.4 adds Visiq::Governor (lib/visiq/governor.rb), the
48
+ # self-refreshing client that fetches GET /rules/bundle itself, so an agent put
49
+ # in Agent Jail is denied on its next call. Additive: every module function
50
+ # below keeps its signature and behaviour.
51
+ VERSION = "0.1.4"
47
52
 
48
53
  # Low-level binding to the compiled core over the C-ABI seam.
49
54
  module Core
@@ -115,8 +120,13 @@ module Visiq
115
120
  # VisIQ packaging/outage bug never disrupts the agent; "closed": raise as
116
121
  # before. Policy outcomes from a successful evaluation are unaffected.
117
122
  def fail_mode
118
- raw = ENV["VISIQ_FAIL_MODE"].to_s.strip.downcase
119
- %w[open closed].include?(raw) ? raw : "open"
123
+ normalize_fail_mode(ENV["VISIQ_FAIL_MODE"]) || "open"
124
+ end
125
+
126
+ # "open" / "closed" (case-insensitive, trimmed), or nil for anything else.
127
+ def normalize_fail_mode(raw)
128
+ v = raw.to_s.strip.downcase
129
+ %w[open closed].include?(v) ? v : nil
120
130
  end
121
131
 
122
132
  # A non-enforced permit decision naming the failure cause — the fail-open
@@ -141,10 +151,17 @@ module Visiq
141
151
  # Evaluate one governed agent event against a rules bundle. Returns the
142
152
  # UnifiedDecision hash: {"decision","allowed","action","retrieval"}.
143
153
  def decide(event, bundle)
154
+ decide_with_fail_mode(event, bundle, fail_mode)
155
+ end
156
+
157
+ # #decide with the fail mode supplied by the caller rather than read from
158
+ # VISIQ_FAIL_MODE. Visiq::Governor uses it so an explicit fail_mode option
159
+ # governs core failures too.
160
+ def decide_with_fail_mode(event, bundle, mode)
144
161
  JSON.parse(Core.evaluate_json(JSON.generate(event), JSON.generate(bundle)))
145
162
  rescue StandardError, Fiddle::DLError => e
146
163
  # Core load / FFI / parse failure — a HARNESS error, never a policy one.
147
- raise if fail_mode == "closed"
164
+ raise if mode == "closed"
148
165
 
149
166
  fail_open_decision(event["operations"] || [], e.message)
150
167
  end
@@ -152,19 +169,33 @@ module Visiq
152
169
  # Gate a tool/action call. Check ["allowed"]; on "mask" apply
153
170
  # ["action"]["argRedactionRules"] to the args before running the tool.
154
171
  def gate_action(bundle, tool_name:, args: nil, agent_id: "agent", target_resource: nil, normalized: nil)
172
+ decide(action_event(tool_name: tool_name, args: args, agent_id: agent_id,
173
+ target_resource: target_resource, normalized: normalized), bundle)
174
+ end
175
+
176
+ # The event #gate_action decides; shared with Visiq::Governor.
177
+ def action_event(tool_name:, args: nil, agent_id: "agent", target_resource: nil, normalized: nil)
155
178
  event = { "operations" => ["action"], "agentId" => agent_id, "toolName" => tool_name }
156
179
  event["toolArgs"] = args unless args.nil?
157
180
  event["targetResource"] = target_resource unless target_resource.nil?
158
181
  event["normalized"] = normalized unless normalized.nil?
159
- decide(event, bundle)
182
+ event
160
183
  end
161
184
 
162
185
  # Gate a retrieval. Inspect ["retrieval"]["action"] — drop on deny/escalate,
163
186
  # redact via ["retrieval"]["redactionRules"] — before content reaches the model.
164
187
  def gate_retrieval(bundle, resource_metadata: nil, resource_type: "document", agent_id: "agent", query: nil)
188
+ decide(retrieval_event(resource_metadata: resource_metadata, resource_type: resource_type,
189
+ agent_id: agent_id, query: query), bundle)
190
+ end
191
+
192
+ # The event #gate_retrieval decides; shared with Visiq::Governor.
193
+ def retrieval_event(resource_metadata: nil, resource_type: "document", agent_id: "agent", query: nil)
165
194
  event = { "operations" => ["retrieval"], "agentId" => agent_id, "resourceType" => resource_type }
166
195
  event["resourceMetadata"] = resource_metadata unless resource_metadata.nil?
167
196
  event["query"] = query unless query.nil?
168
- decide(event, bundle)
197
+ event
169
198
  end
170
199
  end
200
+
201
+ require_relative "visiq/governor"
Binary file
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: visiq
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.4
5
5
  platform: aarch64-linux
6
6
  authors:
7
7
  - VisIQ Labs Team
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-11 00:00:00.000000000 Z
11
+ date: 2026-11-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: fiddle
@@ -39,6 +39,7 @@ files:
39
39
  - LICENSE
40
40
  - README.md
41
41
  - lib/visiq.rb
42
+ - lib/visiq/governor.rb
42
43
  - vendor/libvisiq_core.so
43
44
  homepage: https://docs.visiqlabs.com
44
45
  licenses: