Class: Rigor::Plugin::IoBoundary

Inherits:
Object
  • Object
show all
Defined in:
lib/rigor/plugin/io_boundary.rb,
sig/rigor/plugin/io_boundary.rbs

Overview

Analyzer-side helper plugins go through to read files and (eventually) reach the network. The boundary enforces the active TrustPolicy and accumulates a Cache::Descriptor of every read so plugin contributions stay invalidatable alongside their inputs.

ADR-2 ยง "Plugin Trust and I/O Policy" is the binding contract. The boundary is not a sandbox: a plugin that uses File.read directly bypasses everything here, and the ADR explicitly accepts that trade-off. The discipline is: when plugin code goes through this surface, reads stay within the trust scope and feed the cache descriptor; contributions built on top of out-of-scope reads will not invalidate correctly.

Slice 2 ships a minimal surface:

  • #read_file(path) โ€” validates against the policy, returns the file's contents, and adds a digest-keyed Cache::Descriptor::FileEntry to the boundary's accumulated descriptor.
  • #open_url(url) โ€” fetches the URL when the policy permits it (network_policy: :allowlist plus an allowed_url_hosts match) and raises AccessDeniedError otherwise. v0.1.2 ships the allowlist surface; the default project policy still has network_policy: :disabled so plugins that want network access opt in explicitly through .rigor.yml's plugins_io.network: allowlist plus plugins_io.allowed_url_hosts: [...]. The HTTP fetch is GET-only over HTTPS, capped at URL_TIMEOUT_SECONDS wall time and URL_MAX_BYTES body size; non-2xx responses raise AccessDeniedError so plugin code doesn't have to rescue mid-build.
  • #cache_descriptor โ€” flushes the accumulated entries into a fresh Cache::Descriptor for the contribution that built it. URL fetches contribute ConfigEntry rows keyed "url:#{url}" with the response body's SHA-256 so contributions invalidate when the remote document changes.

Constant Summary collapse

URL_TIMEOUT_SECONDS =
10
URL_MAX_BYTES =
10 * 1024 * 1024

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(policy:, plugin_id:, http_client: DefaultHttpClient.new) ⇒ IoBoundary

Returns a new instance of IoBoundary.

Parameters:

  • policy: (Object)
  • plugin_id: (Object)
  • http_client: (Object) (defaults to: DefaultHttpClient.new)


39
40
41
42
43
44
45
46
# File 'lib/rigor/plugin/io_boundary.rb', line 39

def initialize(policy:, plugin_id:, http_client: DefaultHttpClient.new)
  @policy = policy
  @plugin_id = plugin_id.to_s.dup.freeze
  @file_entries = {}
  @config_entries = {}
  @http_client = http_client
  @mutex = Mutex.new
end

Instance Attribute Details

#plugin_idObject (readonly)

Returns the value of attribute plugin_id.



37
38
39
# File 'lib/rigor/plugin/io_boundary.rb', line 37

def plugin_id
  @plugin_id
end

#policyObject (readonly)

Returns the value of attribute policy.



37
38
39
# File 'lib/rigor/plugin/io_boundary.rb', line 37

def policy
  @policy
end

Instance Method Details

#cache_descriptorObject

Returns a frozen Rigor::Cache::Descriptor (no RBS yet -> untyped).

Returns:

  • (Object)


92
93
94
95
# File 'lib/rigor/plugin/io_boundary.rb', line 92

def cache_descriptor
  files, configs = @mutex.synchronize { [@file_entries.values.dup, @config_entries.values.dup] }
  Cache::Descriptor.new(files: files, configs: configs)
end

#open_url(url) ⇒ String

Fetches the URL when the policy permits it. Returns the response body. Raises AccessDeniedError when the policy is :disabled, the URL scheme is not https, the host is not on the allowlist, the response is non-2xx, the body exceeds URL_MAX_BYTES, or the request times out (URL_TIMEOUT_SECONDS). On success, records a ConfigEntry keyed "url:#{url}" with the body's SHA-256 so the cache descriptor invalidates if the remote document changes.

Parameters:

  • url (Object)

Returns:

  • (String)


72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
# File 'lib/rigor/plugin/io_boundary.rb', line 72

def open_url(url)
  url_string = url.to_s
  unless @policy.allow_url?(url_string)
    raise AccessDeniedError.new(
      "plugin #{@plugin_id.inspect} cannot open URL #{url.inspect}: " \
      "URL is not permitted by the active TrustPolicy " \
      "(network_policy=#{@policy.network_policy} allowed_url_hosts=#{@policy.allowed_url_hosts.inspect})",
      reason: :network_disabled,
      resource: url_string
    )
  end

  body = @http_client.get(url_string, timeout: URL_TIMEOUT_SECONDS, max_bytes: URL_MAX_BYTES)
  record_url_entry(url_string, body)
  body
end

#read_file(path) ⇒ String

Reads the file at path after validating it against the policy. Raises AccessDeniedError when the path is outside every allowed read root. Records a :digest FileEntry so the resulting cache slice invalidates on content change.

Parameters:

  • (Object)

Returns:

  • (String)


51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# File 'lib/rigor/plugin/io_boundary.rb', line 51

def read_file(path)
  absolute = File.expand_path(path.to_s)
  unless @policy.allow_read?(absolute)
    raise AccessDeniedError.new(
      "plugin #{@plugin_id.inspect} cannot read #{absolute.inspect}: " \
      "path is outside the trusted-read scope",
      reason: :read_outside_scope,
      resource: absolute
    )
  end

  contents = File.binread(absolute)
  record_file_entry(absolute, contents)
  contents
end