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
-
.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.
-
.canonical_home ⇒ Object
Resolved Rubino home dir, for the mcp-tokens/ subtree match above.
-
.canonical_path(path) ⇒ Object
Resolves ‘path` through every symlink to its canonical destination, re-joining the missing tail for a not-yet-created target.
-
.category(path) ⇒ Object
Returns the matched-secret category string (truthy) for a secret path, or nil for a normal file.
-
.denied_path_category(target, base) ⇒ Object
Absolute-path / prefix matches (SSH keys, cloud creds, /etc system files), compared against the symlink-resolved target.
-
.home_credential_path?(target) ⇒ Boolean
True when the symlink-resolved
targetis one of the $HOME-relative credential files, or sits inside one of the blocked credential directories (~/.ssh, ~/.aws). -
.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.
-
.secret?(path) ⇒ Boolean
True when ‘path` is a secret.
-
.under_agent_home?(path) ⇒ Boolean
True when ‘path` resolves under the Rubino home directory.
-
.under_path?(target, root) ⇒ Boolean
True when
targetisrootitself or sits under it.
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.
205 206 207 208 209 210 211 212 213 214 215 |
# File 'lib/rubino/security/secret_path.rb', line 205 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_home ⇒ Object
Resolved Rubino home dir, for the mcp-tokens/ subtree match above.
148 149 150 151 152 153 154 155 |
# File 'lib/rubino/security/secret_path.rb', line 148 def canonical_home home = Rubino.home_path return "" if home.nil? || home.to_s.empty? (File.realpath(home) if File.exist?(home)) || File.(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.
226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 |
# File 'lib/rubino/security/secret_path.rb', line 226 def canonical_path(path) return nil if path.nil? || path.to_s.empty? = File.(path.to_s) return File.realpath() if File.exist?() ancestor = 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.
171 172 173 174 175 176 177 178 179 180 181 |
# File 'lib/rubino/security/secret_path.rb', line 171 def category(path) base = File.basename(path.to_s) target = canonical_path(path) || File.(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.
190 191 192 193 194 195 196 197 198 199 200 201 |
# File 'lib/rubino/security/secret_path.rb', line 190 def denied_path_category(target, base) home = File.("~") 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.include?(target) SYSTEM_PREFIXES.each do |prefix| return "system path (#{prefix})" if under_path?(target, 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.
140 141 142 143 144 145 |
# File 'lib/rubino/security/secret_path.rb', line 140 def home_credential_path?(target) home = File.("~") 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.(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 |
.secret?(path) ⇒ Boolean
True when ‘path` is a secret. Thin boolean wrapper over #category.
184 185 186 |
# File 'lib/rubino/security/secret_path.rb', line 184 def secret?(path) !category(path).nil? end |
.under_agent_home?(path) ⇒ Boolean
True when ‘path` resolves under the Rubino home directory.
249 250 251 252 253 254 255 256 257 258 259 260 |
# File 'lib/rubino/security/secret_path.rb', line 249 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.(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 |