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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -1
- data/docs/architecture.md +1 -1
- data/docs/cli.md +18 -9
- data/docs/configuration.md +7 -2
- data/docs/guardrails.md +60 -16
- data/docs/internals/background-tasks.md +11 -0
- data/docs/internals/engine.md +4 -3
- data/docs/internals/participants.md +13 -4
- data/docs/internals/remote-sessions.md +5 -4
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/engine.rb +20 -4
- data/lib/samagotchi/engine_peers.rb +3 -1
- data/lib/samagotchi/guardrails/chi_dirs.rb +64 -0
- data/lib/samagotchi/guardrails/child_boundary.rb +5 -5
- data/lib/samagotchi/guardrails/outside.rb +22 -20
- data/lib/samagotchi/guardrails/pair_boundary.rb +14 -3
- data/lib/samagotchi/guardrails/pair_command.rb +70 -10
- data/lib/samagotchi/guardrails/parent_approvals.rb +8 -11
- data/lib/samagotchi/guardrails/path_resolve.rb +231 -0
- data/lib/samagotchi/guardrails/protected_paths.rb +17 -116
- data/lib/samagotchi/guardrails/review_paths.rb +13 -11
- data/lib/samagotchi/guardrails/rm_targets.rb +5 -5
- data/lib/samagotchi/guardrails/rules.rb +23 -5
- data/lib/samagotchi/guardrails/scratch_writes.rb +3 -2
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +17 -12
- data/lib/samagotchi/guardrails/shell_paths.rb +18 -29
- data/lib/samagotchi/guardrails/targets.rb +11 -13
- data/lib/samagotchi/remote_command.rb +13 -3
- data/lib/samagotchi/remotes.rb +3 -2
- data/lib/samagotchi/review_start.rb +3 -2
- data/lib/samagotchi/session_inbox.rb +17 -0
- data/lib/samagotchi/system_one/self_protect.rb +34 -19
- data/lib/samagotchi/terminal_ui.rb +2 -0
- data/lib/samagotchi/tool_declarations.rb +1 -1
- data/lib/samagotchi/tools/builtins.rb +10 -1
- data/lib/samagotchi/tools/peers.rb +4 -0
- data/lib/samagotchi/tools/task_wait.rb +26 -1
- data/lib/samagotchi/turn_state.rb +23 -4
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +23 -18
- data/lib/samagotchi/web/proxy_gate.rb +8 -6
- data/lib/samagotchi/web/server.rb +29 -4
- data/lib/samagotchi/worker.rb +9 -1
- 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
|
|
9
|
-
# and where the write lands, a vote when either is
|
|
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|
|
|
49
|
+
paths = targets.paths.flat_map { |p| PathResolve.reals(p) }
|
|
47
50
|
@deny.each do |rule, root, reason|
|
|
48
|
-
next unless paths.any? { |p|
|
|
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|
|
|
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
|
-
#
|
|
61
|
-
#
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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 "
|
|
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:
|
|
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
|
|
49
|
-
|
|
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
|
-
|
|
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 =
|
|
86
|
+
real_root = PathResolve.real(@root)
|
|
85
87
|
|
|
86
88
|
paths.each do |path|
|
|
87
89
|
given = File.expand_path(path)
|
|
88
|
-
reals =
|
|
89
|
-
return OUTSIDE unless reals.all? { |real|
|
|
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) ||
|
|
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|
|
|
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|
|
|
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 "
|
|
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 =
|
|
42
|
-
roots = tmp_roots
|
|
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|
|
|
66
|
-
roots.none? { |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
|
|
20
|
-
#
|
|
21
|
-
#
|
|
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
|
-
|
|
107
|
-
|
|
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|
|
|
37
|
-
|
|
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 "
|
|
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
|
-
# (
|
|
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.
|
|
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+:
|
|
153
|
-
# be told from the text (a variable
|
|
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 =
|
|
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 ?
|
|
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 "
|
|
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 =
|
|
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 =
|
|
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|
|
|
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
|
-
|
|
68
|
-
next true if roots.any? { |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|
|
|
69
|
+
real.match?(HOOKS_PART) && tmp_roots.none? { |tmp| PathResolve.under?(real, tmp) }
|
|
71
70
|
end
|
|
72
71
|
end
|
|
73
72
|
|
|
74
|
-
|
|
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 (
|
|
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 (
|
|
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,
|
|
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 "
|
|
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
|
-
# (
|
|
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),
|
|
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,
|
|
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
|
-
|
|
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?(
|
|
443
|
-
File.dirname(dir).casecmp?(
|
|
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
|
|
126
|
-
# needs none), false after the
|
|
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
|
|
data/lib/samagotchi/remotes.rb
CHANGED
|
@@ -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
|
|
29
|
-
# none
|
|
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::
|
|
189
|
-
real = Guardrails::
|
|
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
|