Module: Rubino::Security::SecretPath

Defined in:
lib/rubino/security/secret_path.rb

Overview

ONE “is this a secret/credential path?” predicate for the WRITE-side approval gate (Security::ApprovalPolicy#decide → :ask when a write/edit/ multi_edit/apply_patch targets a secret). Writing/clobbering a secret requires explicit user approval; READING one is allowed unprompted (the field norm, #480) and has no gate, so this predicate is no longer consulted on the read path.

The gate itself is in ApprovalPolicy/ToolExecutor (interactive →approval dropdown; approved → tool proceeds; denied → refused; headless →fails closed). This module is pure detection — it never prompts, blocks, or touches IO beyond symlink resolution.

Constant Summary collapse

BASENAME_RE =

Credential/secret BASENAMES, in any directory.

/
  \A\.env(\..+)?\z | \A\.envrc\z |
  \A\.netrc\z | \A\.pgpass\z | \A\.npmrc\z | \A\.pypirc\z |
  \A\.git-credentials\z |
  \A\.bashrc\z | \A\.zshrc\z | \A\.profile\z | \A\.bash_profile\z | \A\.zprofile\z
/x
BLOCKED_PROJECT_ENV_BASENAMES =

Common secret-bearing project-local environment file basenames blocked on the structured READ path (read/grep), ported 1:1 from Hermes’ ‘agent/file_safety._BLOCKED_PROJECT_ENV_BASENAMES`. Deliberately an EXACT set (not BASENAME_RE) so `.env.example` — the documented-shape substitute — is NOT blocked, matching Hermes.

[
  ".env", ".env.local", ".env.development",
  ".env.production", ".env.test", ".env.staging", ".envrc"
].to_set.freeze
BLOCKED_HOME_CREDENTIAL_BASENAMES =

Agent-home credential-store basenames blocked on the structured READ path, mirroring Hermes’ ‘get_read_block_error` credential_file_names (auth.json / .anthropic_oauth.json / .env / mcp-tokens/ live under the agent home). rubino’s token store is rubino.sqlite3.

[
  ".env", "auth.json", "auth.lock",
  ".anthropic_oauth.json", "rubino.sqlite3"
].to_set.freeze
BLOCKED_HOME_CREDENTIAL_FILES =

$HOME-relative credential FILES blocked on the structured READ path. Ported from Hermes’ ‘build_write_denied_paths` (file_safety.py:35-58): the SSH key/identity files (`~/.ssh/config`), `~/.netrc`, and `~/.git-credentials`. QA finding: these leaked because they were only on the WRITE denylist, so a `read` of `~/.ssh/id_rsa` etc. returned the key material — and the redactor’s uppercase-only ENV_ASSIGN_RE misses lowercase ‘aws_secret_access_key`. rubino has no Anthropic PKCE store; its OAuth/credential equivalents live under the agent home (~/.rubino) and are already covered above.

[
  File.join(".ssh", "id_rsa"),
  File.join(".ssh", "id_ed25519"),
  File.join(".ssh", "authorized_keys"),
  File.join(".ssh", "config"),
  ".netrc",
  ".git-credentials"
].freeze
BLOCKED_HOME_CREDENTIAL_DIRS =

$HOME-relative credential DIRECTORIES blocked on the structured READ path (anything inside is treated as secret). Started from Hermes’ ‘build_write_denied_prefixes` (file_safety.py:66-82): `~/.ssh` and `~/.aws`. This is what blocks `~/.aws/credentials` (the lowercase `aws_secret_access_key` the redactor doesn’t mask).

Extended (#537) so the READ deny-set covers the same home-credential stores the WRITE-side detector (HOME_PREFIXES) already treats as secret: ‘~/.kube` (bearer tokens in config), `~/.docker` (registry auth in config.json), `~/.config/gh` (GitHub tokens in hosts.yml), `~/.gnupg` (private keyrings) and `~/.azure` (cloud creds). Previously read-allowed and unredacted, so those secrets reached the model. Defense-in-depth layered on the trust model — NOT a complete boundary (the shell tool runs as the same OS user and can still read them).

[
  ".ssh", ".aws", ".kube", ".docker", ".gnupg", ".azure",
  File.join(".config", "gh")
].freeze
BLOCKED_CREDENTIAL_BASENAMES =

Credential BASENAMES blocked on the structured READ path wherever they sit — HOME or project-local (#537). ‘.netrc`/`.git-credentials` were only blocked at their exact $HOME path, so a project-local copy was read-allowed and unredacted. Defense-in-depth, not a boundary.

[".netrc", ".git-credentials"].to_set.freeze
HOME_PREFIXES =

Home-relative credential subtrees (resolved against $HOME).

[
  ".ssh", ".aws", ".gnupg", ".kube", ".docker", ".azure",
  ".config/gh", ".config/gcloud"
].freeze
SYSTEM_PATHS =

Absolute system paths / prefixes.

["/etc/sudoers", "/etc/passwd", "/etc/shadow"].freeze
SYSTEM_PREFIXES =
["/etc/sudoers.d", "/etc/systemd"].freeze

Class Method Summary collapse

Class Method Details

.agent_home_category(path, base, target) ⇒ Object

Agent-home (~/.rubino) auth/secret material: the home .env, the token store sqlite DB, oauth files, mcp-tokens/, and .key/.pem material.



217
218
219
220
221
222
223
224
225
226
227
# File 'lib/rubino/security/secret_path.rb', line 217

def agent_home_category(path, base, target)
  return unless under_agent_home?(path)

  lower = target.downcase
  return unless base == ".env" || base.match?(BASENAME_RE) ||
                base == "rubino.sqlite3" || base.end_with?(".sqlite3") ||
                lower.include?("oauth") || lower.include?("/mcp-tokens/") ||
                base.end_with?(".key") || base.end_with?(".pem")

  "agent-home secret (#{base})"
end

.canonical_homeObject

Resolved Rubino home dir, for the mcp-tokens/ subtree match above.



160
161
162
163
164
165
166
167
# File 'lib/rubino/security/secret_path.rb', line 160

def canonical_home
  home = Rubino.home_path
  return "" if home.nil? || home.to_s.empty?

  (File.realpath(home) if File.exist?(home)) || File.expand_path(home)
rescue StandardError
  ""
end

.canonical_path(path) ⇒ Object

Resolves ‘path` through every symlink to its canonical destination, re-joining the missing tail for a not-yet-created target. Mirrors Tools::Base#canonical_path so the write-creates-new-file flow resolves to the same place the tool will write.



238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
# File 'lib/rubino/security/secret_path.rb', line 238

def canonical_path(path)
  return nil if path.nil? || path.to_s.empty?

  expanded = File.expand_path(path.to_s)
  return File.realpath(expanded) if File.exist?(expanded)

  ancestor = expanded
  tail     = []
  until File.exist?(ancestor)
    parent = File.dirname(ancestor)
    break if parent == ancestor

    tail.unshift(File.basename(ancestor))
    ancestor = parent
  end
  return nil unless File.exist?(ancestor)

  File.join(File.realpath(ancestor), *tail)
rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
  nil
end

.category(path) ⇒ Object

Returns the matched-secret category string (truthy) for a secret path, or nil for a normal file. ‘path` may be relative or absolute; it is resolved through every symlink first so an in-workspace link to ~/.ssh can’t slip past the basename/prefix checks.



183
184
185
186
187
188
189
190
191
192
193
# File 'lib/rubino/security/secret_path.rb', line 183

def category(path)
  base   = File.basename(path.to_s)
  target = canonical_path(path) || File.expand_path(path.to_s)

  return "credential file (#{base})" if base.match?(BASENAME_RE)
  if (cat = denied_path_category(target, base))
    return cat
  end

  agent_home_category(path, base, target)
end

.denied_path_category(target, base) ⇒ Object

Absolute-path / prefix matches (SSH keys, cloud creds, /etc system files), compared against the symlink-resolved target.



202
203
204
205
206
207
208
209
210
211
212
213
# File 'lib/rubino/security/secret_path.rb', line 202

def denied_path_category(target, base)
  home = resolved_root(File.expand_path("~"))
  HOME_PREFIXES.each do |rel|
    return "credential directory (~/#{rel})" if under_path?(target, File.join(home, rel))
  end
  return "system file (#{base})" if SYSTEM_PATHS.any? { |p| target == resolved_root(p) }

  SYSTEM_PREFIXES.each do |prefix|
    return "system path (#{prefix})" if under_path?(target, resolved_root(prefix))
  end
  nil
end

.home_credential_path?(target) ⇒ Boolean

True when the symlink-resolved target is one of the $HOME-relative credential files, or sits inside one of the blocked credential directories (~/.ssh, ~/.aws). Mirrors Hermes’ write-deny exact-path + prefix split, applied here to the READ gate.

Returns:

  • (Boolean)


140
141
142
143
144
145
# File 'lib/rubino/security/secret_path.rb', line 140

def home_credential_path?(target)
  home = resolved_root(File.expand_path("~"))
  return true if BLOCKED_HOME_CREDENTIAL_FILES.any? { |rel| target == File.join(home, rel) }

  BLOCKED_HOME_CREDENTIAL_DIRS.any? { |rel| under_path?(target, File.join(home, rel)) }
end

.read_block_error(path) ⇒ Object

Returns a model-facing error string when a structured READ (read/grep) targets a denied secret/credential path, or nil when the read is allowed. Ported 1:1 from Hermes’ ‘get_read_block_error` plus the home credential files/dirs Hermes write-denies (file_safety.py:35-82): the project-local .env family ANYWHERE on disk, the agent-home credential stores and the mcp-tokens/ tree, and the user’s SSH/AWS/ kube/docker/gnupg/azure/gh credential stores under $HOME (#537), plus ‘.netrc`/`.git-credentials` wherever they sit (HOME or project-local).

**NOT a security boundary** — the shell runs as the same OS user and can still ‘cat .env`, where the value is REDACTED (see Redactor). The read block is defense-in-depth: it returns a clear error that most models respect, and surfaces an audit trail. Mirrors the framing in Hermes’ module docstring.



103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
# File 'lib/rubino/security/secret_path.rb', line 103

def read_block_error(path)
  base   = File.basename(path.to_s)
  target = canonical_path(path) || File.expand_path(path.to_s)

  if under_agent_home?(path) && (BLOCKED_HOME_CREDENTIAL_BASENAMES.include?(base) ||
       base.end_with?(".sqlite3") || target.downcase.include?("oauth") ||
       target.downcase.include?("#{File::SEPARATOR}mcp-tokens#{File::SEPARATOR}") ||
       under_path?(target, File.join(canonical_home, "mcp-tokens")))
    return "Access denied: #{path} is a Rubino credential store and " \
           "cannot be read directly. Provider tools consume these " \
           "credentials through internal channels. (Defense-in-depth — " \
           "not a security boundary; the shell tool can still bypass.)"
  end

  if BLOCKED_PROJECT_ENV_BASENAMES.include?(base)
    return "Access denied: #{path} is a secret-bearing environment file " \
           "and cannot be read to prevent credential leakage. If you need " \
           "to check the file structure, read .env.example instead. " \
           "(Defense-in-depth — not a security boundary; the shell tool " \
           "can still bypass.)"
  end

  if BLOCKED_CREDENTIAL_BASENAMES.include?(base) || home_credential_path?(target)
    return "Access denied: #{path} is a private credential store " \
           "(SSH key, cloud/kube/docker/gh credentials, gnupg keyring, " \
           "netrc, or git-credentials) and cannot be read to prevent " \
           "credential leakage. (Defense-in-depth — not a security " \
           "boundary; the shell tool can still bypass.)"
  end

  nil
end

.resolved_root(path) ⇒ Object

Symlink-resolves a comparison ROOT through the SAME #canonical_path used on target, so the two sides match even when a system symlink sits on the path. Without this, macOS’ symlinks defeat the match: ‘/etc` →`/private/etc` makes `/etc/sudoers` (and a non-existent `/etc/shadow`) resolve past SYSTEM_PATHS, and a `$TMPDIR`/$HOME under `/var` →`/private/var` slips the home credential dirs. Using canonical_path (not bare realpath) resolves the existing ancestor of a NON-existent root too, so `/etc/shadow` still classifies on a host where it doesn’t exist.



155
156
157
# File 'lib/rubino/security/secret_path.rb', line 155

def resolved_root(path)
  canonical_path(path) || path
end

.secret?(path) ⇒ Boolean

True when ‘path` is a secret. Thin boolean wrapper over #category.

Returns:

  • (Boolean)


196
197
198
# File 'lib/rubino/security/secret_path.rb', line 196

def secret?(path)
  !category(path).nil?
end

.under_agent_home?(path) ⇒ Boolean

True when ‘path` resolves under the Rubino home directory.

Returns:

  • (Boolean)


261
262
263
264
265
266
267
268
269
270
271
272
# File 'lib/rubino/security/secret_path.rb', line 261

def under_agent_home?(path)
  home = Rubino.home_path
  return false if home.nil? || home.to_s.empty?

  home_real   = (File.realpath(home) if File.exist?(home)) || File.expand_path(home)
  target_real = canonical_path(path)
  return false unless target_real

  target_real == home_real || target_real.start_with?("#{home_real}#{File::SEPARATOR}")
rescue StandardError
  false
end

.under_path?(target, root) ⇒ Boolean

True when target is root itself or sits under it.

Returns:

  • (Boolean)


230
231
232
# File 'lib/rubino/security/secret_path.rb', line 230

def under_path?(target, root)
  target == root || target.start_with?("#{root}#{File::SEPARATOR}")
end