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

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?

  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.



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.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.



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.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.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.

Returns:

  • (Boolean)


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.

Returns:

  • (Boolean)


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.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)


89
90
91
# File 'lib/rubino/security/secret_path.rb', line 89

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