Module: Rubino::Security::SecretPath
- Defined in:
- lib/rubino/security/secret_path.rb
Overview
ONE “is this a secret/credential path?” predicate, shared by the tool layer (read/grep/glob refuse to leak; write/edit refuse to clobber) and the approval layer (Security::ApprovalPolicy#decide → :ask). Previously the read side and the write side carried two parallel denylists; the maintainer decision (#446) is that reading OR writing a secret both require the SAME explicit user approval over the SAME set — so the set and the predicate live here, once.
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- 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_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.
-
.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.
76 77 78 79 80 81 82 83 84 85 86 |
# File 'lib/rubino/security/secret_path.rb', line 76 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_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.
97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 |
# File 'lib/rubino/security/secret_path.rb', line 97 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.
42 43 44 45 46 47 48 49 50 51 52 |
# File 'lib/rubino/security/secret_path.rb', line 42 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.
61 62 63 64 65 66 67 68 69 70 71 72 |
# File 'lib/rubino/security/secret_path.rb', line 61 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 |
.secret?(path) ⇒ Boolean
True when ‘path` is a secret. Thin boolean wrapper over #category.
55 56 57 |
# File 'lib/rubino/security/secret_path.rb', line 55 def secret?(path) !category(path).nil? end |
.under_agent_home?(path) ⇒ Boolean
True when ‘path` resolves under the Rubino home directory.
120 121 122 123 124 125 126 127 128 129 130 131 |
# File 'lib/rubino/security/secret_path.rb', line 120 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 |