samagotchi 0.129.0 → 0.130.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -1
  3. data/docs/architecture.md +1 -1
  4. data/docs/cli.md +18 -9
  5. data/docs/configuration.md +7 -2
  6. data/docs/guardrails.md +60 -16
  7. data/docs/internals/background-tasks.md +11 -0
  8. data/docs/internals/engine.md +4 -3
  9. data/docs/internals/participants.md +13 -4
  10. data/docs/internals/remote-sessions.md +5 -4
  11. data/docs/sessions.md +1 -1
  12. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  13. data/lib/samagotchi/engine.rb +20 -4
  14. data/lib/samagotchi/engine_peers.rb +3 -1
  15. data/lib/samagotchi/guardrails/chi_dirs.rb +64 -0
  16. data/lib/samagotchi/guardrails/child_boundary.rb +5 -5
  17. data/lib/samagotchi/guardrails/outside.rb +22 -20
  18. data/lib/samagotchi/guardrails/pair_boundary.rb +14 -3
  19. data/lib/samagotchi/guardrails/pair_command.rb +70 -10
  20. data/lib/samagotchi/guardrails/parent_approvals.rb +8 -11
  21. data/lib/samagotchi/guardrails/path_resolve.rb +231 -0
  22. data/lib/samagotchi/guardrails/protected_paths.rb +17 -116
  23. data/lib/samagotchi/guardrails/review_paths.rb +13 -11
  24. data/lib/samagotchi/guardrails/rm_targets.rb +5 -5
  25. data/lib/samagotchi/guardrails/rules.rb +23 -5
  26. data/lib/samagotchi/guardrails/scratch_writes.rb +3 -2
  27. data/lib/samagotchi/guardrails/shell_git_dirs.rb +17 -12
  28. data/lib/samagotchi/guardrails/shell_paths.rb +18 -29
  29. data/lib/samagotchi/guardrails/targets.rb +11 -13
  30. data/lib/samagotchi/remote_command.rb +13 -3
  31. data/lib/samagotchi/remotes.rb +3 -2
  32. data/lib/samagotchi/review_start.rb +3 -2
  33. data/lib/samagotchi/session_inbox.rb +17 -0
  34. data/lib/samagotchi/system_one/self_protect.rb +34 -19
  35. data/lib/samagotchi/terminal_ui.rb +2 -0
  36. data/lib/samagotchi/tool_declarations.rb +1 -1
  37. data/lib/samagotchi/tools/builtins.rb +10 -1
  38. data/lib/samagotchi/tools/peers.rb +4 -0
  39. data/lib/samagotchi/tools/task_wait.rb +26 -1
  40. data/lib/samagotchi/turn_state.rb +23 -4
  41. data/lib/samagotchi/version.rb +1 -1
  42. data/lib/samagotchi/web/app.rb +23 -18
  43. data/lib/samagotchi/web/proxy_gate.rb +8 -6
  44. data/lib/samagotchi/web/server.rb +29 -4
  45. data/lib/samagotchi/worker.rb +9 -1
  46. metadata +3 -1
@@ -1,12 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "path_resolve"
4
+
3
5
  module Samagotchi
4
6
  module Guardrails
5
7
  # Core checks on what the file tools (write, edit, memory_write) touch,
6
8
  # after the hooks and before the rules. Paths are compared with
7
9
  # symlinks resolved (on the longest existing parent, `..` taken
8
- # physically, a dangling symlink followed: .reals, the lexical answer
9
- # and where the write lands, a vote when either is protected).
10
+ # physically, a dangling symlink followed: PathResolve.reals, the
11
+ # lexical answer and where the write lands, a vote when either is
12
+ # protected).
10
13
  # deny: the approval store's dir, installed bundles (.bundles/:
11
14
  # hooks, rules, manifests) and attached context (a source's
12
15
  # command runs later, outside the gate; chi context writes
@@ -43,131 +46,29 @@ module Samagotchi
43
46
  targets = verdict.targets
44
47
  return verdict unless targets && FILE_TOOLS.include?(targets.tool)
45
48
 
46
- paths = targets.paths.flat_map { |p| self.class.reals(p) }
49
+ paths = targets.paths.flat_map { |p| PathResolve.reals(p) }
47
50
  @deny.each do |rule, root, reason|
48
- next unless paths.any? { |p| self.class.within?(p, root) }
51
+ next unless paths.any? { |p| PathResolve.within?(p, root) }
49
52
 
50
53
  verdict.deny!(reason, rule: rule, source: SOURCE, decided_by: "core")
51
54
  end
52
55
  @ask.each do |rule, root, reason|
53
- next unless paths.any? { |p| self.class.within?(p, root) }
56
+ next unless paths.any? { |p| PathResolve.within?(p, root) }
54
57
 
55
58
  verdict.ask!(reason, scopes: %w[once session], rule: rule, source: SOURCE, decided_by: "core")
56
59
  end
57
60
  verdict
58
61
  end
59
62
 
60
- # +path+ as an absolute path the way the kernel opens it: ~ expanded,
61
- # relative to +base+, and each `..` taken from where the dir before it
62
- # really is (a `..` after a symlinked dir climbs out of its target,
63
- # not back to the link's parent as File.expand_path has it). It
64
- # differs from File.expand_path only for such a climb; the parts
65
- # after the last `..` are kept as given (symlinks not resolved).
66
- # A shell's cd may land at either (ShellPaths::Walk keeps both); a file
67
- # argument and a tool's path go through this.
68
- # @param path [String]
69
- # @param base [String, nil] for a relative path (Dir.pwd when nil)
70
- def self.expand(path, base = nil)
71
- path = path.to_s
72
- head, sep, tail = path.partition("/")
73
- path = "#{File.expand_path(head)}#{sep}#{tail}" if head.start_with?("~")
74
- path = File.join(File.expand_path(base || Dir.pwd), path) unless path.start_with?("/")
75
- parts = []
76
- path.split("/").each do |part|
77
- next if part.empty? || part == "."
78
-
79
- part == ".." ? parts = climb(parts) : parts << part
80
- end
81
- "/#{parts.join("/")}"
82
- end
83
-
84
- # The parts of +parts+'s parent dir: the lexical one unless the dir
85
- # is (or goes through) a symlink whose target's parent is elsewhere.
86
- def self.climb(parts)
87
- lexical = parts[0...-1]
88
- dir = "/#{parts.join("/")}"
89
- return lexical unless parts.any? && File.directory?(dir)
90
-
91
- physical = File.dirname(File.realpath(dir))
92
- return lexical if File.realpath("/#{lexical.join("/")}") == physical
93
-
94
- physical.split("/").reject(&:empty?)
95
- rescue SystemCallError
96
- lexical
97
- end
98
- private_class_method :climb
99
-
100
- # How many symlinks .real follows on a path that doesn't exist
101
- # (about the kernel's own ELOOP limit).
102
- MAX_LINKS = 40
103
-
104
- # +path+ (.expand) with symlinks resolved on its longest existing
105
- # parent: where the kernel lands for it. A part that is a dangling
106
- # symlink is followed to its target (a write there creates the
107
- # target), up to MAX_LINKS times; the missing tail is kept as given.
108
- def self.real(path)
109
- path = expand(path)
110
- MAX_LINKS.times do
111
- resolved = existing_prefix_real(path)
112
- return resolved unless resolved.is_a?(Array)
113
-
114
- path = resolved.first
115
- end
116
- path
117
- rescue SystemCallError
118
- path
119
- end
120
-
121
- # The old, lexical answer: File.expand_path (lexical `..`), then
122
- # realpath of the longest existing prefix, stopping at a dangling
123
- # symlink. .reals keeps it beside .real.
124
- def self.lexical_real(path)
125
- path = File.expand_path(path)
126
- rest = []
127
- current = path
128
- until File.exist?(current) || current == File.dirname(current)
129
- rest.unshift(File.basename(current))
130
- current = File.dirname(current)
131
- end
132
- File.join(File.realpath(current), *rest)
133
- rescue SystemCallError
134
- path
135
- end
136
-
137
- # Every place +path+ may be judged as: .lexical_real (what these
138
- # checks always looked at) and .real (where the kernel lands), one
139
- # entry when they agree. A check that holds for any of them (a
140
- # protected dir, a secret) or refuses unless all of them are inside
141
- # (the project, tmp) refuses at least what it did with the lexical
142
- # answer alone.
143
- # @return [Array<String>]
144
- def self.reals(path) = [lexical_real(path), real(path)].uniq
145
-
146
- # realpath of +path+'s longest existing prefix with the rest
147
- # appended (a String), or [the path with the first dangling symlink
148
- # on the way replaced by its target] to resolve again.
149
- def self.existing_prefix_real(path)
150
- rest = []
151
- current = path
152
- until File.exist?(current) || current == File.dirname(current)
153
- if File.symlink?(current)
154
- target = expand(File.readlink(current), File.dirname(current))
155
- return [expand(File.join(target, *rest))]
156
- end
157
- rest.unshift(File.basename(current))
158
- current = File.dirname(current)
159
- end
160
- File.join(File.realpath(current), *rest)
161
- end
162
- private_class_method :existing_prefix_real
163
-
164
- # Whether +path+ (resolved) is +root+ or in it, +root+ resolved
165
- # either way (.reals).
166
- def self.within?(path, root)
167
- return false if root.nil? || root.to_s.empty?
168
-
169
- reals(root.to_s.chomp("/")).any? { |dir| path == dir || path.start_with?(File.join(dir, "")) }
170
- end
63
+ # The path primitives (PathResolve) as this class has long named
64
+ # them; specs and older callers reach them here.
65
+ def self.expand(path, base = nil) = PathResolve.expand(path, base)
66
+ def self.real(path) = PathResolve.real(path)
67
+ def self.lexical_real(path) = PathResolve.lexical_real(path)
68
+ def self.reals(path) = PathResolve.reals(path)
69
+ def self.within?(path, root) = PathResolve.within?(path, root)
70
+ def self.under?(path, dir) = PathResolve.under?(path, dir)
71
+ def self.folds_case?(path) = PathResolve.folds_case?(path)
171
72
  end
172
73
  end
173
74
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "protected_paths"
3
+ require_relative "chi_dirs"
4
+ require_relative "path_resolve"
4
5
  require_relative "shell_paths"
5
6
  require_relative "../paths"
6
7
 
@@ -11,7 +12,7 @@ module Samagotchi
11
12
  # (chi's state and config dirs, home secret dirs, project secret files),
12
13
  # checked on the path as given and as it resolves (a symlink in the
13
14
  # project to ~/.ssh is ~/.ssh; a `..` after a symlinked dir climbs out
14
- # of its target, as the kernel opens it: ProtectedPaths.reals).
15
+ # of its target, as the kernel opens it: PathResolve.reals).
15
16
  # ReviewBoundary asks it for every read of a participant's turn; a
16
17
  # review session's subjects (chi review, the participant page's Plan)
17
18
  # go through the same check.
@@ -45,8 +46,9 @@ module Samagotchi
45
46
  SECRET = :secret
46
47
  HOME_ROOT = :home_root
47
48
 
48
- # chi's state dir and config dir, as Paths has them now.
49
- def self.chi_dirs = [Paths.state_dir, File.join(Paths.config_home, Paths::APP_DIR)]
49
+ # chi's state dir, config dir and a hooks dir configured outside it
50
+ # (ChiDirs.review).
51
+ def self.chi_dirs = ChiDirs.review
50
52
 
51
53
  # Whether +root+ is the home folder or a folder above it (symlinks
52
54
  # resolved on both): no project to keep a participant's turn in. A
@@ -56,7 +58,7 @@ module Samagotchi
56
58
  def self.home_root?(root, home)
57
59
  return false if home.to_s.empty? || root.to_s.empty?
58
60
 
59
- ShellPaths.within?(ProtectedPaths.real(home.to_s), ProtectedPaths.real(root.to_s))
61
+ PathResolve.under?(PathResolve.real(home.to_s), PathResolve.real(root.to_s))
60
62
  end
61
63
 
62
64
  # @param root [String, nil] the session's project root (the work tree
@@ -81,12 +83,12 @@ module Samagotchi
81
83
 
82
84
  return HOME_ROOT if ReviewPaths.home_root?(@root, @home)
83
85
 
84
- real_root = ProtectedPaths.real(@root)
86
+ real_root = PathResolve.real(@root)
85
87
 
86
88
  paths.each do |path|
87
89
  given = File.expand_path(path)
88
- reals = ProtectedPaths.reals(path)
89
- return OUTSIDE unless reals.all? { |real| ShellPaths.within?(real, real_root) }
90
+ reals = PathResolve.reals(path)
91
+ return OUTSIDE unless reals.all? { |real| PathResolve.under?(real, real_root) }
90
92
  return SECRET if secret?(given) || reals.any? { |real| secret?(real) }
91
93
  end
92
94
  nil
@@ -100,7 +102,7 @@ module Samagotchi
100
102
  def secret_refusal(paths)
101
103
  paths.each do |path|
102
104
  given = File.expand_path(path)
103
- return SECRET if secret?(given) || ProtectedPaths.reals(path).any? { |real| secret?(real) }
105
+ return SECRET if secret?(given) || PathResolve.reals(path).any? { |real| secret?(real) }
104
106
  end
105
107
  nil
106
108
  end
@@ -113,13 +115,13 @@ module Samagotchi
113
115
  @denied_roots ||= begin
114
116
  roots = @denied_dirs.compact.map { |dir| File.expand_path(dir) }
115
117
  roots += (HOME_SECRET_DIRS + HOME_SECRET_FILES).map { |name| File.join(@home, name) } unless @home.empty?
116
- (roots + roots.map { |root| ProtectedPaths.real(root) }).uniq
118
+ (roots + roots.map { |root| PathResolve.real(root) }).uniq
117
119
  end
118
120
  end
119
121
 
120
122
  # Whether +path+ (absolute) is on the read denylist.
121
123
  def secret?(path)
122
- return true if denied_roots.any? { |root| ShellPaths.within?(path, root) }
124
+ return true if denied_roots.any? { |root| PathResolve.under?(path, root) }
123
125
 
124
126
  parts = path.split(File::SEPARATOR)
125
127
  return true if parts.any? { |part| SECRET_FILE.match?(part) }
@@ -2,7 +2,7 @@
2
2
 
3
3
  require_relative "shell_lex"
4
4
  require_relative "shell_paths"
5
- require_relative "protected_paths"
5
+ require_relative "path_resolve"
6
6
 
7
7
  module Samagotchi
8
8
  module Guardrails
@@ -38,8 +38,8 @@ module Samagotchi
38
38
  code = ShellLex.code_text(command.to_s)
39
39
  return true if tokens.any? { |_, value| value.include?(ShellLex::SUBST) } && code.match?(/\brm\b/)
40
40
 
41
- session = ProtectedPaths.real(session_root.to_s)
42
- roots = tmp_roots.reject { |tmp| ShellPaths.within?(session, tmp) }
41
+ session = PathResolve.real(session_root.to_s)
42
+ roots = PathResolve.tmp_roots(session: session, roots: tmp_roots)
43
43
  walk = ShellPaths::Walk.new(cwd, home, env)
44
44
  ShellLex.simple_commands(tokens).any? do |words|
45
45
  wide = words.is_a?(Array) && words.any?(&RM_WORD) && wide?(words, walk, roots)
@@ -62,8 +62,8 @@ module Samagotchi
62
62
 
63
63
  targets.empty? || targets.any? do |target|
64
64
  paths = walk.paths(target)
65
- paths.empty? || paths.flat_map { |path| ProtectedPaths.reals(path) }.any? do |real|
66
- roots.none? { |tmp| real.start_with?(File.join(tmp, "")) }
65
+ paths.empty? || paths.flat_map { |path| PathResolve.reals(path) }.any? do |real|
66
+ roots.none? { |tmp| PathResolve.strictly_under?(real, tmp, fold: File.exist?(tmp)) }
67
67
  end
68
68
  end
69
69
  end
@@ -16,9 +16,12 @@ module Samagotchi
16
16
  # command: a Ruby regex on a shell tool's command, the body of a
17
17
  # heredoc its command only reads as data left out
18
18
  # (Targets#rule_command, ShellLex.code_text)
19
- # path: "outside_repo", or a glob on the resolved paths (absolute
20
- # or "**/…" globs match the absolute path; others the path
21
- # relative to the repo root, the cwd outside one)
19
+ # path: "outside_repo", or a glob on the paths (absolute or "**/…"
20
+ # globs match the absolute path; others the path relative
21
+ # to the repo root, the cwd outside one): as typed
22
+ # (Targets#paths) and, for an ask or deny, as they resolve
23
+ # (PathResolve.reals, relative to the root as it resolves),
24
+ # so a symlink into .git/hooks doesn't slip past a hooks rule
22
25
  # git: "outside_repo": a shell call runs a mutating git
23
26
  # subcommand outside the session's repo (ShellGitDirs)
24
27
  # models: "small" (Guardrails::ModelSize, guardrails.small_models),
@@ -102,12 +105,27 @@ module Samagotchi
102
105
 
103
106
  base = targets.repo_root || targets.cwd
104
107
  glob = path.start_with?("~") ? File.expand_path(path) : path
108
+ absolute = glob.start_with?("/", "**")
109
+ real_base = nil
105
110
  targets.paths.any? do |p|
106
- candidate = glob.start_with?("/", "**") ? p : relative(p, base)
107
- candidate && File.fnmatch(glob, candidate, GLOB_FLAGS)
111
+ forms = [[p, base]]
112
+ # A resolved match only adds an ask or a deny; were a verdict
113
+ # ever to let a call through, a symlink into its glob must not.
114
+ if resolved_match?
115
+ real_base ||= PathResolve.real(base)
116
+ forms += PathResolve.reals(p).map { |real| [real, real_base] }
117
+ end
118
+ forms.any? do |form, from|
119
+ candidate = absolute ? form : relative(form, from)
120
+ candidate && File.fnmatch(glob, candidate, GLOB_FLAGS)
121
+ end
108
122
  end
109
123
  end
110
124
 
125
+ # Whether a path: glob also matches the paths as they resolve: for
126
+ # an ask or a deny only.
127
+ def resolved_match? = VERDICTS.include?(verdict.to_s)
128
+
111
129
  def relative(path, base)
112
130
  prefix = File.join(base, "")
113
131
  path.start_with?(prefix) ? path.delete_prefix(prefix) : nil
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "path_resolve"
3
4
  require_relative "protected_paths"
4
5
  require_relative "../memory_paths"
5
6
 
@@ -33,8 +34,8 @@ module Samagotchi
33
34
  targets = verdict.targets
34
35
  return false unless targets && %w[write edit].include?(targets.tool)
35
36
 
36
- targets.paths.flat_map { |path| ProtectedPaths.reals(path) }.any? do |real|
37
- ProtectedPaths.within?(real, @memories_dir.call)
37
+ targets.paths.flat_map { |path| PathResolve.reals(path) }.any? do |real|
38
+ PathResolve.within?(real, @memories_dir.call)
38
39
  end
39
40
  end
40
41
  end
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "shell_lex"
4
- require_relative "protected_paths"
4
+ require_relative "path_resolve"
5
+ require_relative "shell_paths"
5
6
 
6
7
  module Samagotchi
7
8
  module Guardrails
@@ -11,11 +12,13 @@ module Samagotchi
11
12
  # it follows cd/pushd/popd, ( … ) subshells, git -C/--git-dir/--work-tree
12
13
  # and GIT_DIR/GIT_WORK_TREE. Where a `..` climbs out of a symlinked
13
14
  # dir's target, its lexical path and its physical one
14
- # (ProtectedPaths.expand, the only look at the disk) both count: a cd
15
+ # (PathResolve.expand, the only look at the disk) both count: a cd
15
16
  # may land at either (the shell's logical cd chdirs to the physical
16
17
  # path when the logical one doesn't exist, and always with -P), so the
17
18
  # walk keeps every dir the command may be in, and git's -C and dir
18
- # options are opened the kernel's way; such a git counts from each. It
19
+ # options are opened the kernel's way; such a git counts from each. A
20
+ # leading ~, $HOME, XDG dir or $TMPDIR is expanded as ShellPaths::Walk
21
+ # expands it (PathResolve.expand_word). It
19
22
  # catches honest mistakes (cd into another checkout and commit
20
23
  # there); sh -c, scripts, aliases and $(…) bodies get through. Never
21
24
  # raises on odd input.
@@ -32,9 +35,11 @@ module Samagotchi
32
35
  # @param home [String] for ~, $HOME, ${HOME} and a bare cd
33
36
  # @param env [Hash{String=>String}] the call's env: (task_create's):
34
37
  # its GIT_DIR / GIT_WORK_TREE hold for each git unless it sets its own
38
+ # @param vars [Hash{String=>String}] the shell's variables a word's
39
+ # $XDG_* / $TMPDIR expand from (ENV, with the call's env: over it)
35
40
  # @return [Array<String, Symbol>] absolute dirs and :unknown, unique
36
- def self.for(command, cwd:, home: Dir.home, env: {})
37
- Walk.new(cwd, home, env).run(ShellLex.simple_commands(ShellLex.lex(command.to_s)))
41
+ def self.for(command, cwd:, home: Dir.home, env: {}, vars: ENV)
42
+ Walk.new(cwd, home, env, vars).run(ShellLex.simple_commands(ShellLex.lex(command.to_s)))
38
43
  rescue ArgumentError, EncodingError
39
44
  [] # a NUL byte, invalid UTF-8: the shell would refuse it too
40
45
  end
@@ -45,9 +50,10 @@ module Samagotchi
45
50
  # More dirs than this after a cd: the dir is unknown.
46
51
  MAX_DIRS = 8
47
52
 
48
- def initialize(cwd, home, env = {})
53
+ def initialize(cwd, home, env = {}, vars = ENV)
49
54
  @dirs = [cwd]
50
55
  @home = home
56
+ @vars = vars
51
57
  @env = env.slice("GIT_DIR", "GIT_WORK_TREE")
52
58
  @subshells = []
53
59
  @pushed = []
@@ -149,17 +155,16 @@ module Samagotchi
149
155
  end
150
156
 
151
157
  # An absolute path for +arg+ against +dir+ (`..` lexical, or
152
- # +physical+: ProtectedPaths.expand); nil when it can't
153
- # be told from the text (a variable, a substitution, a relative path
154
- # against an unknown dir).
158
+ # +physical+: PathResolve.expand); nil when it can't
159
+ # be told from the text (a variable but those PathResolve.expand_word
160
+ # expands, a substitution, a relative path against an unknown dir).
155
161
  def expand(arg, dir, physical: false)
156
162
  return nil if arg.include?(ShellLex::SUBST)
157
163
 
158
- arg = arg.sub(%r{\A(~|\$HOME|\$\{HOME\})(?=/|\z)}) { @home }
159
- return nil if arg.include?("$") || arg.start_with?("~")
164
+ arg = PathResolve.expand_word(arg, home: @home, env: @vars) or return nil
160
165
  return nil unless arg.start_with?("/") || dir
161
166
 
162
- physical ? ProtectedPaths.expand(arg, dir) : File.expand_path(arg, dir)
167
+ physical ? PathResolve.expand(arg, dir) : File.expand_path(arg, dir)
163
168
  end
164
169
  end
165
170
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "shell_lex"
4
- require_relative "protected_paths"
4
+ require_relative "path_resolve"
5
5
  require_relative "../redacted_inspect"
6
6
 
7
7
  module Samagotchi
@@ -24,11 +24,10 @@ module Samagotchi
24
24
  SCRIPT = /[\s;&|<>()'"`]/
25
25
  GLOB = /[*?\[\]{}]/
26
26
  HOOKS_PART = %r{(?:\A|/)\.git/hooks(?:/|\z)}
27
- # $HOME, ${HOME} and the XDG dirs at a word's start.
28
- EXPANSION = /\A\$(?:\{(HOME|XDG_[A-Z_]+|TMPDIR)\}|(HOME|XDG_[A-Z_]+|TMPDIR)\b)/
27
+ # $HOME, ${HOME} and the XDG dirs at a word's start (PathResolve's).
28
+ EXPANSION = PathResolve::EXPANSION
29
29
  # Where XDG dirs default when unset.
30
- XDG_DEFAULTS = { "XDG_CONFIG_HOME" => ".config", "XDG_STATE_HOME" => ".local/state",
31
- "XDG_DATA_HOME" => ".local/share", "XDG_CACHE_HOME" => ".cache" }.freeze
30
+ XDG_DEFAULTS = PathResolve::XDG_DEFAULTS
32
31
  # A redirection's operator at a word's start (>>file, 2>file, <file).
33
32
  REDIRECT_PREFIX = /\A\d*(?:&>>?|>>?&?|<)/
34
33
 
@@ -40,7 +39,7 @@ module Samagotchi
40
39
  # @param cwd [String, nil] where the command starts
41
40
  # @param tmp_roots [Array<String>] where a .git/hooks part doesn't count
42
41
  def touches?(command, dirs:, text:, cwd:, home: Dir.home, env: ENV, tmp_roots: [])
43
- roots = dirs.compact.map { |dir| ProtectedPaths.real(dir.to_s.chomp("/")) }.reject(&:empty?).uniq
42
+ roots = dirs.compact.map { |dir| PathResolve.real(dir.to_s.chomp("/")) }.reject(&:empty?).uniq
44
43
  any_path?(command, text: text, cwd: cwd, home: home, env: env) { |path| names?(path, roots, tmp_roots) }
45
44
  end
46
45
 
@@ -64,20 +63,22 @@ module Samagotchi
64
63
  end
65
64
 
66
65
  def names?(path, roots, tmp_roots)
67
- ProtectedPaths.reals(path).any? do |real|
68
- next true if roots.any? { |root| within?(real, root) }
66
+ PathResolve.reals(path).any? do |real|
67
+ next true if roots.any? { |root| PathResolve.under?(real, root) }
69
68
 
70
- real.match?(HOOKS_PART) && tmp_roots.none? { |tmp| within?(real, tmp) }
69
+ real.match?(HOOKS_PART) && tmp_roots.none? { |tmp| PathResolve.under?(real, tmp) }
71
70
  end
72
71
  end
73
72
 
74
- def within?(path, root) = path == root || path.start_with?(File.join(root, ""))
73
+ # Whether +path+ is +root+ or in it, cased alike on a disk that folds
74
+ # case (PathResolve.under?).
75
+ def within?(path, root) = PathResolve.under?(path, root)
75
76
 
76
77
  # Follows the current dir and gives, per simple command, each word
77
78
  # with its path (nil when it can't be resolved). A word is opened the
78
79
  # kernel's way, so a `..` after a symlinked dir climbs out of its
79
80
  # target: such a word comes twice, with its lexical path and its
80
- # physical one (ProtectedPaths.expand), and a check reads both. A cd
81
+ # physical one (PathResolve.expand), and a check reads both. A cd
81
82
  # or pushd to such a word may land at either (the shell's logical cd
82
83
  # chdirs to the physical path when the logical one doesn't exist, and
83
84
  # always with -P, set -P or zsh's chase_links/chase_dots), so the walk
@@ -165,12 +166,12 @@ module Samagotchi
165
166
 
166
167
  # +word+ as a file argument against +dir+: its lexical path, and its
167
168
  # physical one when a `..` in it climbs out of a symlinked dir's
168
- # target (ProtectedPaths.expand); [] when it can't be resolved.
169
+ # target (PathResolve.expand); [] when it can't be resolved.
169
170
  def paths_for(word, dir)
170
171
  lexical = resolve(word, dir) or return []
171
172
  return [lexical] unless word.include?("..")
172
173
 
173
- [lexical, ProtectedPaths.expand(expand(word), dir)].uniq
174
+ [lexical, PathResolve.expand(expand(word), dir)].uniq
174
175
  end
175
176
 
176
177
  # An absolute path for +word+ against +dir+ with `..` taken
@@ -185,23 +186,11 @@ module Samagotchi
185
186
  dir && File.expand_path(word, dir)
186
187
  end
187
188
 
188
- def expand(word)
189
- word = word.sub(%r{\A~(?=/|\z)}) { @home }
190
- word = word.sub(EXPANSION) { value(Regexp.last_match(1) || Regexp.last_match(2)) || "$" }
191
- return nil if word.include?("$") || word.start_with?("~")
192
-
193
- word
194
- end
195
-
196
- def value(name)
197
- return @home if name == "HOME"
198
-
199
- set = @env[name].to_s
200
- return set unless set.empty?
201
-
202
- XDG_DEFAULTS[name] && File.join(@home, XDG_DEFAULTS[name])
203
- end
189
+ def expand(word) = PathResolve.expand_word(word, home: @home, env: @env)
204
190
  end
191
+
192
+ # PathResolve.expand_word, as this module has long named it.
193
+ def expand_word(word, home:, env:) = PathResolve.expand_word(word, home: home, env: env)
205
194
  end
206
195
  end
207
196
  end
@@ -13,7 +13,8 @@ require_relative "shell_lex"
13
13
  require_relative "read_only_shell"
14
14
  require_relative "shell_paths"
15
15
  require_relative "rm_targets"
16
- require_relative "protected_paths"
16
+ require_relative "chi_dirs"
17
+ require_relative "path_resolve"
17
18
  require_relative "../memory_paths"
18
19
  require_relative "../redacted_inspect"
19
20
 
@@ -234,11 +235,8 @@ module Samagotchi
234
235
  end
235
236
 
236
237
  # chi's own dirs (ParentApprovals.chi_dirs) and the session repo's
237
- # git hooks dir.
238
- def self.chi_dirs(context, base)
239
- require_relative "parent_approvals"
240
- ParentApprovals.chi_dirs + [context.hooks_dir(base)].compact
241
- end
238
+ # git hooks dir (ChiDirs.touches).
239
+ def self.chi_dirs(context, base) = ChiDirs.touches(context, base)
242
240
 
243
241
  # A shell call's env: as the tool reads it (an object, or JSON text
244
242
  # for one): {} for none, and for one the tool would refuse (it then
@@ -260,13 +258,13 @@ module Samagotchi
260
258
  # +path+ (a file tool's, as Tools::ToolPath has it) absolute against
261
259
  # +base+: its lexical path (File.expand_path), and the one the kernel
262
260
  # opens when a `..` in it climbs out of a symlinked dir's target
263
- # (ProtectedPaths.expand) after it; [] for a blank path. Every check
261
+ # (PathResolve.expand) after it; [] for a blank path. Every check
264
262
  # reads all of #paths, so it judges both.
265
263
  def self.absolutes(path, base)
266
264
  path = Tools::ToolPath.normalize(path)
267
265
  return [] if path.empty?
268
266
 
269
- [File.expand_path(path, base), ProtectedPaths.expand(path, base)].uniq
267
+ [File.expand_path(path, base), PathResolve.expand(path, base)].uniq
270
268
  end
271
269
 
272
270
  # The file memory_write would write; nil when the call is invalid.
@@ -386,8 +384,8 @@ module Samagotchi
386
384
  def git_dirs
387
385
  @git_dirs ||= if shell?
388
386
  # chi's own GIT_DIR is not the command's concern
389
- shell_envs.each_with_index.flat_map do |(home, _), i|
390
- ShellGitDirs.for(@command, cwd: @cwd, home: home, env: i.zero? ? {} : @env)
387
+ shell_envs.each_with_index.flat_map do |(home, vars), i|
388
+ ShellGitDirs.for(@command, cwd: @cwd, home: home, env: i.zero? ? {} : @env, vars: vars)
391
389
  end.uniq
392
390
  else
393
391
  []
@@ -435,12 +433,12 @@ module Samagotchi
435
433
  # disk MODEL_NOTES_x.md in Samagotchi/Memories is the same file chi
436
434
  # loads.
437
435
  def self.prompt_memory_path?(path)
438
- ProtectedPaths.reals(path).any? do |real|
436
+ PathResolve.reals(path).any? do |real|
439
437
  next false unless File.basename(real).downcase.match?(PROMPT_MEMORY)
440
438
 
441
439
  dir = File.dirname(real)
442
- dir.casecmp?(ProtectedPaths.real(MemoryPaths.system_dir)) ||
443
- File.dirname(dir).casecmp?(ProtectedPaths.real(MemoryPaths.projects_dir))
440
+ dir.casecmp?(PathResolve.real(MemoryPaths.system_dir)) ||
441
+ File.dirname(dir).casecmp?(PathResolve.real(MemoryPaths.projects_dir))
444
442
  end
445
443
  rescue StandardError
446
444
  false
@@ -122,8 +122,9 @@ module Samagotchi
122
122
  0
123
123
  end
124
124
 
125
- # The token from its file or stdin; nil for none (a loopback chi web
126
- # needs none), false after the error line.
125
+ # The token from its file or stdin; nil for none (a chi web on
126
+ # loopback only, no LAN mode or proxy, needs none), false after the
127
+ # error line.
127
128
  def read_token(options)
128
129
  text = if options[:token_file]
129
130
  begin
@@ -145,17 +146,26 @@ module Samagotchi
145
146
  end
146
147
 
147
148
  # GET /api/info with the token: the answer, or nil after the line that
148
- # says why it can't be used.
149
+ # says why it can't be used. With no token, a chi web on the LAN or
150
+ # behind a proxy reached on its loopback (an SSH tunnel) answers chi's
151
+ # probe (its checked fields, no cwd) and every other route 401: it
152
+ # needs the token.
149
153
  def check(entry)
150
154
  reply = @transport.call(entry).call(:get, "/api/info")
151
155
  problem = SessionAccess::Remote.info_problem(entry, reply)
152
156
  return fail_line(problem.message) if problem
157
+ return fail_line(token_needed(entry)) unless reply.json.key?("cwd")
153
158
 
154
159
  reply.json
155
160
  rescue SessionAccess::RemoteFailed => e
156
161
  fail_line("#{e.message}: start chi web on #{entry.host} with web.host: lan")
157
162
  end
158
163
 
164
+ def token_needed(entry)
165
+ "#{entry.label}: its chi web needs its access token (its LAN link carries it, after ?token=; or its " \
166
+ "web-token file): chi remote add #{entry.name} #{entry.url} --token-file FILE"
167
+ end
168
+
159
169
  def lan_words(found, entry)
160
170
  return "loopback only" unless found["lan"]
161
171
 
@@ -25,8 +25,9 @@ module Samagotchi
25
25
  NAME = /\A[A-Za-z0-9][A-Za-z0-9_-]{0,39}\z/
26
26
 
27
27
  # One remote: its +name+, chi web's +url+ (scheme, host and port, no
28
- # path) and its access +token+ (nil for a loopback chi web, which needs
29
- # none). #inspect leaves the token out.
28
+ # path) and its access +token+ (nil for a chi web on loopback only, no
29
+ # LAN mode or proxy: it alone needs none, its loopback reached through
30
+ # an SSH tunnel). #inspect leaves the token out.
30
31
  Entry = Data.define(:name, :url, :token) do
31
32
  # The host as an address, an IPv6 one bare (::1, not [::1]).
32
33
  def host = URI(url).hostname
@@ -6,6 +6,7 @@ require_relative "session_setup"
6
6
  require_relative "session_chain"
7
7
  require_relative "recap_store"
8
8
  require_relative "guardrails/context"
9
+ require_relative "guardrails/path_resolve"
9
10
  require_relative "guardrails/review_paths"
10
11
  require_relative "participant_mode"
11
12
 
@@ -185,8 +186,8 @@ module Samagotchi
185
186
  "only reads."
186
187
 
187
188
  def relative(full, root)
188
- real_root = Guardrails::ProtectedPaths.real(root)
189
- real = Guardrails::ProtectedPaths.real(full)
189
+ real_root = Guardrails::PathResolve.real(root)
190
+ real = Guardrails::PathResolve.real(full)
190
191
  real.delete_prefix(File.join(real_root, ""))
191
192
  end
192
193
  end