maf 0.1.0 → 0.2.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 +131 -0
- data/README.md +11 -10
- data/assets/analyst +29 -51
- data/assets/coord +23 -20
- data/assets/dashboard +1 -5
- data/assets/dispatcher +69 -286
- data/assets/harness-hooks/context-watch.rb +5 -4
- data/assets/lima/maf.yaml.erb +61 -0
- data/lib/maf/bootstrap/script_planner.rb +1 -1
- data/lib/maf/cli.rb +3 -0
- data/lib/maf/flow/agent_links.rb +2 -2
- data/lib/maf/flow/bootstrapper.rb +5 -5
- data/lib/maf/flow/codex_hooks.rb +12 -1
- data/lib/maf/flow/generator.rb +1 -1
- data/lib/maf/flow/harness_linker.rb +2 -2
- data/lib/maf/flow/hook_files.rb +6 -0
- data/lib/maf/flow/hook_installer.rb +6 -20
- data/lib/maf/flow/manifest.rb +2 -2
- data/lib/maf/flow/mcp_config.rb +8 -22
- data/lib/maf/flow/mcp_installer.rb +3 -3
- data/lib/maf/flow/models.rb +6 -41
- data/lib/maf/flow/options.rb +4 -2
- data/lib/maf/flow/prompt_builder.rb +7 -14
- data/lib/maf/flow/prompt_text.rb +7 -11
- data/lib/maf/flow/report.rb +2 -4
- data/lib/maf/flow/role_files.rb +4 -19
- data/lib/maf/flow/validator.rb +2 -2
- data/lib/maf/flow.rb +4 -20
- data/lib/maf/menu.rb +1 -1
- data/lib/maf/migrate/role_files.rb +3 -1
- data/lib/maf/retire.rb +42 -6
- data/lib/maf/setup_agent/dispatch.rb +1 -1
- data/lib/maf/setup_agent/launcher.rb +15 -66
- data/lib/maf/setup_agent/pty_relay.rb +75 -0
- data/lib/maf/setup_agent/role_file.rb +1 -1
- data/lib/maf/setup_agent/runtime_hooks.rb +1 -12
- data/lib/maf/setup_agent.rb +1 -3
- data/lib/maf/shared/harness/base.rb +57 -0
- data/lib/maf/shared/harness/defaults.rb +140 -0
- data/lib/maf/shared/harness/output.rb +55 -0
- data/lib/maf/shared/harness/traits.rb +35 -0
- data/lib/maf/shared/harness/vm_credential.rb +36 -0
- data/lib/maf/shared/harness.rb +12 -0
- data/lib/maf/shared/harnesses/claude.rb +134 -0
- data/lib/maf/shared/harnesses/codex.rb +173 -0
- data/lib/maf/shared/harnesses/cursor.rb +64 -0
- data/lib/maf/shared/harnesses/hermes.rb +112 -0
- data/lib/maf/shared/harnesses/opencode.rb +169 -0
- data/lib/maf/shared/processes.rb +20 -0
- data/lib/maf/shared/sandbox/bwrap.rb +128 -0
- data/lib/maf/shared/sandbox/harness_files.rb +50 -0
- data/lib/maf/shared/sandbox/links.rb +28 -0
- data/lib/maf/shared/sandbox/paths.rb +124 -0
- data/lib/maf/shared/sandbox/references.rb +64 -0
- data/lib/maf/shared/sandbox/seatbelt.rb +86 -0
- data/lib/maf/shared/sandbox.rb +84 -0
- data/lib/maf/team_command.rb +3 -3
- data/lib/maf/uninstall/manifest.rb +10 -8
- data/lib/maf/uninstall/mcp_entries.rb +4 -5
- data/lib/maf/uninstall/notes.rb +13 -2
- data/lib/maf/uninstall/role_files.rb +11 -10
- data/lib/maf/uninstall/runner.rb +4 -3
- data/lib/maf/uninstall/scripts.rb +1 -1
- data/lib/maf/uninstall/vm_files.rb +16 -0
- data/lib/maf/uninstall.rb +20 -5
- data/lib/maf/untrack.rb +6 -26
- data/lib/maf/version.rb +1 -1
- data/lib/maf/vm/login.rb +52 -0
- data/lib/maf/vm/options.rb +41 -0
- data/lib/maf/vm/template.rb +44 -0
- data/lib/maf/vm.rb +94 -0
- data/lib/maf/worker_control.rb +4 -7
- data/lib/maf/workers.rb +27 -0
- metadata +30 -11
- data/lib/maf/setup_agent/hermes_launcher.rb +0 -34
- data/lib/maf/setup_agent/hermes_skill.rb +0 -26
- data/templates/claude.md.erb +0 -16
- data/templates/codex.md.erb +0 -7
- data/templates/hermes.md.erb +0 -12
- data/templates/opencode.md.erb +0 -24
data/lib/maf/shared/processes.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
# maf shared library - code that the maf CLI and the scripts in .maf/bin share.
|
|
4
4
|
# The installer copies this folder to .maf/lib/maf/shared/. maf update replaces it.
|
|
5
5
|
# Use the Ruby stdlib only: a script loads this file without a gem bundle.
|
|
6
|
+
require "time"
|
|
6
7
|
|
|
7
8
|
module Maf
|
|
8
9
|
module Shared
|
|
@@ -20,6 +21,25 @@ module Maf
|
|
|
20
21
|
true
|
|
21
22
|
end
|
|
22
23
|
|
|
24
|
+
def self.same?(pid, started)
|
|
25
|
+
!started.to_s.empty? && alive?(pid) && started_at(pid) == started.to_s
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
LEGACY_LIVE_WINDOW = 3600
|
|
29
|
+
|
|
30
|
+
def self.live?(entry)
|
|
31
|
+
return false unless alive?(entry["pid"])
|
|
32
|
+
|
|
33
|
+
recorded = entry["started"].to_s
|
|
34
|
+
recorded.empty? ? recent?(entry["seen_at"]) : recorded == started_at(entry["pid"])
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.recent?(seen_at)
|
|
38
|
+
Time.now - Time.parse(seen_at.to_s) < LEGACY_LIVE_WINDOW
|
|
39
|
+
rescue ArgumentError
|
|
40
|
+
false
|
|
41
|
+
end
|
|
42
|
+
|
|
23
43
|
# The start time as ps prints it, or "" when ps fails.
|
|
24
44
|
def self.started_at(pid)
|
|
25
45
|
IO.popen(PS_ENV, ["ps", "-o", "lstart=", "-p", pid.to_s], err: File::NULL, &:read).to_s.strip
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# maf shared library - code that the maf CLI and the scripts in .maf/bin share.
|
|
4
|
+
# The installer copies this folder to .maf/lib/maf/shared/. maf update replaces it.
|
|
5
|
+
# Use the Ruby stdlib only: a script loads this file without a gem bundle.
|
|
6
|
+
require "fileutils"
|
|
7
|
+
|
|
8
|
+
module Maf
|
|
9
|
+
module Shared
|
|
10
|
+
module Sandbox
|
|
11
|
+
# Bwrap runs a command in a Linux bubblewrap sandbox.
|
|
12
|
+
module Bwrap
|
|
13
|
+
COMMAND = "bwrap"
|
|
14
|
+
MARKER = "MAF_BWRAP"
|
|
15
|
+
BASE = ["--ro-bind", "/", "/", "--dev-bind", "/dev", "/dev", "--tmpfs", "/dev/shm", "--proc", "/proc",
|
|
16
|
+
"--tmpfs", "/tmp", "--setenv", MARKER, "1"].freeze
|
|
17
|
+
MASKED_DIRS = %w[/run/user /run/dbus].freeze
|
|
18
|
+
MASKED_SOCKETS = %w[/run/docker.sock /run/podman/podman.sock /run/containerd/containerd.sock].freeze
|
|
19
|
+
HOOK = "# Project hook for coord worktree. See assets/worktree-env.example.rb in the maf gem.\n"
|
|
20
|
+
|
|
21
|
+
def self.wrap(cmd, paths)
|
|
22
|
+
return cmd if inside?
|
|
23
|
+
|
|
24
|
+
[binary, *BASE, *masks, *home, *binds(paths), *protects(paths), "--die-with-parent", "--", *cmd]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.available? = !binary.nil?
|
|
28
|
+
|
|
29
|
+
def self.binary
|
|
30
|
+
dirs = ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).reject { |dir| agent_dir?(File.expand_path(dir)) }
|
|
31
|
+
dirs.map { |dir| File.join(dir, COMMAND) }.find { |file| File.executable?(file) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.agent_dir?(dir)
|
|
35
|
+
writes = Array(Sandbox.policy["write"]).map { |path| File.expand_path(path, Project.root) }
|
|
36
|
+
tops = [Project.root, "/tmp", "/var/tmp", ENV.fetch("TMPDIR", nil), *writes].compact.map { _1.chomp("/") }
|
|
37
|
+
tops.any? { |top| dir == top || dir.start_with?("#{top}/") }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.require!
|
|
41
|
+
inside? || (available? && overlay?) || abort("maf: sandbox mode bwrap needs bubblewrap 0.10 or later.")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.inside?(mountinfo = "/proc/self/mountinfo")
|
|
45
|
+
ENV[MARKER] == "1" && File.foreach(mountinfo).any? { |line| home_overlay?(line) }
|
|
46
|
+
rescue SystemCallError
|
|
47
|
+
false
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# mountinfo writes a space in a path as \040.
|
|
51
|
+
def self.home_overlay?(line) = line.include?(" #{mount_path(Dir.home)} ") && line.include?(" - overlay ")
|
|
52
|
+
def self.mount_path(path) = path.gsub(" ", "\\\\040")
|
|
53
|
+
|
|
54
|
+
def self.masks
|
|
55
|
+
runtime = ENV.fetch("XDG_RUNTIME_DIR", "")
|
|
56
|
+
MASKED_DIRS.select { |dir| File.directory?(dir) }.flat_map { |dir| ["--tmpfs", dir] } +
|
|
57
|
+
(runtime.start_with?("/run/user/") ? ["--dir", runtime] : []) +
|
|
58
|
+
MASKED_SOCKETS.select { |path| File.exist?(path) }.flat_map { |path| ["--ro-bind", "/dev/null", path] }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.home = ["--overlay-src", Dir.home, "--tmp-overlay", Dir.home]
|
|
62
|
+
def self.overlay? = IO.popen([binary, "--help"], err: %i[child out], &:read).include?("--tmp-overlay")
|
|
63
|
+
|
|
64
|
+
def self.binds(paths)
|
|
65
|
+
FileUtils.mkdir_p(paths.harness_dirs)
|
|
66
|
+
paths.prefixes.each { |file| File.exist?(file) || File.write(file, "{}\n") }
|
|
67
|
+
writable(paths).select { |path| File.exist?(path) }.flat_map { |path| ["--bind", path, path] }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.writable(paths)
|
|
71
|
+
((paths.dirs - ["/tmp"]) + paths.harness_dirs + paths.prefixes).reject { |path| masked?(path) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.masked?(path)
|
|
75
|
+
(MASKED_DIRS + MASKED_SOCKETS).any? do |mask|
|
|
76
|
+
path == mask || path.start_with?("#{mask}/") || mask.start_with?("#{path.chomp("/")}/")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# A mount point cannot be renamed, so each parent folder is bound onto itself.
|
|
81
|
+
# Then each protected path is bound read-only.
|
|
82
|
+
def self.protects(paths)
|
|
83
|
+
create_protected(paths)
|
|
84
|
+
parents = bound_parents(paths).select { |dir| File.directory?(dir) }.flat_map { |dir| ["--bind", dir, dir] }
|
|
85
|
+
parents + paths.protected.select { |path| File.exist?(path) }.flat_map { |path| ["--ro-bind", path, path] }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def self.bound_parents(paths)
|
|
89
|
+
tops = writable(paths)
|
|
90
|
+
paths.parents.select { |dir| tops.any? { |top| dir == top || dir.start_with?("#{top.chomp("/")}/") } }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# A bind needs an existing path. Else the agent could create a protected
|
|
94
|
+
# path, so the folders, the worktree hook, and the harness config are created first.
|
|
95
|
+
def self.create_protected(paths)
|
|
96
|
+
paths.protected_dirs.each { |dir| make(dir) }
|
|
97
|
+
create_hook(paths.coord_file("worktree-env.rb"))
|
|
98
|
+
paths.placeholders.each { |path| placeholder(path) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def self.create_hook(hook)
|
|
102
|
+
File.exist?(File.dirname(hook)) && !File.exist?(hook) && File.write(hook, HOOK)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def self.placeholder(path)
|
|
106
|
+
return if File.exist?(path) || File.symlink?(path) || !File.directory?(File.dirname(path))
|
|
107
|
+
|
|
108
|
+
name = File.basename(path)
|
|
109
|
+
File.extname(name).empty? && !name.start_with?(".") ? make(path) : write(path, empty_text(name))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def self.write(path, text)
|
|
113
|
+
File.write(path, text)
|
|
114
|
+
rescue SystemCallError
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def self.empty_text(name) = name.end_with?(".json", ".jsonc") ? "{}\n" : ""
|
|
119
|
+
|
|
120
|
+
def self.make(dir)
|
|
121
|
+
FileUtils.mkdir_p(dir)
|
|
122
|
+
rescue SystemCallError
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# maf shared library - code that the maf CLI and the scripts in .maf/bin share.
|
|
4
|
+
# The installer copies this folder to .maf/lib/maf/shared/. maf update replaces it.
|
|
5
|
+
# Use the Ruby stdlib only: a script loads this file without a gem bundle.
|
|
6
|
+
|
|
7
|
+
module Maf
|
|
8
|
+
module Shared
|
|
9
|
+
module Sandbox
|
|
10
|
+
# HarnessFiles holds the state paths and the protected paths in the folders of the harnesses.
|
|
11
|
+
class HarnessFiles
|
|
12
|
+
def initialize(harnesses, real)
|
|
13
|
+
@harnesses = harnesses
|
|
14
|
+
@real = real
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def dirs = @dirs ||= @real.call(@harnesses.flat_map(&:sandbox_dirs))
|
|
18
|
+
def prefixes = @real.call(@harnesses.flat_map(&:sandbox_prefixes))
|
|
19
|
+
def placeholders = @harnesses.flat_map(&:sandbox_placeholders)
|
|
20
|
+
def config = @harnesses.flat_map(&:sandbox_protected).select { |path| File.file?(path) }
|
|
21
|
+
|
|
22
|
+
def state
|
|
23
|
+
@state ||= @harnesses.flat_map(&:sandbox_state).map do |pattern|
|
|
24
|
+
pattern = File.expand_path(pattern)
|
|
25
|
+
File.join(@real.call([File.dirname(pattern)]).first, File.basename(pattern))
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def state?(path)
|
|
30
|
+
dir = dirs.find { |folder| path.start_with?("#{folder}/") } or return false
|
|
31
|
+
top = File.join(dir, path.delete_prefix("#{dir}/").split("/").first)
|
|
32
|
+
state.any? { |pattern| File.fnmatch?(pattern, top, File::FNM_DOTMATCH | File::FNM_PATHNAME) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def locked
|
|
36
|
+
@locked ||= (dirs.flat_map { |dir| children(dir) }.reject { |path| state?(path) } +
|
|
37
|
+
@harnesses.flat_map(&:sandbox_protected)).uniq
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def children(dir)
|
|
43
|
+
Dir.children(dir).map { |name| File.join(dir, name) }
|
|
44
|
+
rescue SystemCallError
|
|
45
|
+
[]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# maf shared library - code that the maf CLI and the scripts in .maf/bin share.
|
|
4
|
+
# The installer copies this folder to .maf/lib/maf/shared/. maf update replaces it.
|
|
5
|
+
# Use the Ruby stdlib only: a script loads this file without a gem bundle.
|
|
6
|
+
require "fileutils"
|
|
7
|
+
|
|
8
|
+
module Maf
|
|
9
|
+
module Shared
|
|
10
|
+
module Sandbox
|
|
11
|
+
# Links puts back a protected symlink that the agent changed in a bwrap run.
|
|
12
|
+
module Links
|
|
13
|
+
def self.record(paths) = paths.select { |path| File.symlink?(path) }.to_h { |path| [path, File.readlink(path)] }
|
|
14
|
+
|
|
15
|
+
def self.restore(links) = links.each { |link, target| restore_link(link, target) }
|
|
16
|
+
|
|
17
|
+
def self.restore_link(link, target)
|
|
18
|
+
return if File.symlink?(link) && File.readlink(link) == target
|
|
19
|
+
|
|
20
|
+
warn "maf: sandbox: the agent changed the protected link #{link}. maf put it back."
|
|
21
|
+
FileUtils.rm_rf(link) && File.symlink(target, link)
|
|
22
|
+
rescue SystemCallError => e
|
|
23
|
+
warn "maf: sandbox: cannot put back the link #{link}: #{e.message}"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# maf shared library - code that the maf CLI and the scripts in .maf/bin share.
|
|
4
|
+
# The installer copies this folder to .maf/lib/maf/shared/. maf update replaces it.
|
|
5
|
+
# Use the Ruby stdlib only: a script loads this file without a gem bundle.
|
|
6
|
+
require_relative "../project"
|
|
7
|
+
require_relative "references"
|
|
8
|
+
require_relative "harness_files"
|
|
9
|
+
|
|
10
|
+
module Maf
|
|
11
|
+
module Shared
|
|
12
|
+
module Sandbox
|
|
13
|
+
# Paths holds the real paths of a sandbox.
|
|
14
|
+
class Paths
|
|
15
|
+
COMMON = ["/tmp", "/var/tmp"].freeze
|
|
16
|
+
PROTECTED = [".maf/sandbox.json", ".maf/bin", ".maf/lib", ".maf/env.sh", ".maf/coordination/harness-hooks",
|
|
17
|
+
".maf/claude", ".maf/mcp", ".git/hooks", ".git/config", ".git/commondir",
|
|
18
|
+
".git/config.worktree"].freeze
|
|
19
|
+
COORD_PROTECTED = %w[message-hooks taskrc worktree-env.rb taskdata/hooks].freeze
|
|
20
|
+
COORD_DIRS = %w[message-hooks taskdata/hooks].freeze
|
|
21
|
+
PROTECTED_DIRS = [".maf/bin", ".maf/lib", ".maf/claude", ".maf/mcp", ".git/hooks"].freeze
|
|
22
|
+
WORKTREE_PROTECTED = [".maf/bin", ".maf/lib", ".maf/env.sh", ".maf/coordination/harness-hooks"].freeze
|
|
23
|
+
FLOW_CONFIG = %w[.maf/claude/settings.json .maf/mcp/*.json].freeze
|
|
24
|
+
|
|
25
|
+
attr_reader :root
|
|
26
|
+
|
|
27
|
+
def initialize(harnesses, root, coord_dir, extra)
|
|
28
|
+
@harness = HarnessFiles.new(harnesses, ->(paths) { real(paths) })
|
|
29
|
+
@root = root
|
|
30
|
+
@coord_dir = coord_dir || File.join(root, ".maf", "coordination")
|
|
31
|
+
@extra = extra.map { |path| File.expand_path(path, root) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def dirs = @dirs ||= real([@root, @coord_dir, *@extra, ENV.fetch("TMPDIR", nil), *COMMON])
|
|
35
|
+
def harness_dirs = @harness.dirs
|
|
36
|
+
def state = @harness.state
|
|
37
|
+
def harness_files = @harness.locked
|
|
38
|
+
def placeholders = @harness.placeholders
|
|
39
|
+
def prefixes = @harness.prefixes
|
|
40
|
+
|
|
41
|
+
def references
|
|
42
|
+
keep = ->(path) { writable?(path) }
|
|
43
|
+
References.of(@harness.config + project_config + flow_config, @root, keep) +
|
|
44
|
+
worktrees.flat_map { |dir| References.of(config_in(dir), dir, keep) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def existing = dirs.select { |dir| File.exist?(dir) }
|
|
48
|
+
def sockets = real([@root, @coord_dir])
|
|
49
|
+
|
|
50
|
+
def protected
|
|
51
|
+
@protected ||= begin
|
|
52
|
+
paths = [*PROTECTED.map { |path| File.join(@root, path) }, *project_config, *coord_files, *worktree_links,
|
|
53
|
+
*worktree_files, *submodule_files, *harness_files, *references]
|
|
54
|
+
(real(paths) + links(paths)).uniq
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def project_config = config_in(@root)
|
|
59
|
+
def config_in(dir) = Harness.all.flat_map(&:sandbox_project).uniq.map { |path| File.join(dir, path) }
|
|
60
|
+
def flow_config = FLOW_CONFIG.flat_map { |glob| Dir.glob(File.join(@root, glob)) }
|
|
61
|
+
|
|
62
|
+
def protected_dirs
|
|
63
|
+
real([*PROTECTED_DIRS.map { |path| File.join(@root, path) }, *COORD_DIRS.map { |name| coord_file(name) }])
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def coord_files = COORD_PROTECTED.map { |name| coord_file(name) }
|
|
67
|
+
def coord_file(name) = File.join(@coord_dir, name)
|
|
68
|
+
|
|
69
|
+
def worktree_links
|
|
70
|
+
Dir.glob(File.join(@root, Project::WORKTREES_DIR, "*", ".git")) +
|
|
71
|
+
Dir.glob(File.join(@root, ".git", "worktrees", "*", "{commondir,gitdir,config.worktree}"))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def worktree_files
|
|
75
|
+
worktrees.flat_map do |dir|
|
|
76
|
+
[*WORKTREE_PROTECTED.map { |path| File.join(dir, path) }, *config_in(dir)]
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
GIT_FILES = %w[config hooks].freeze
|
|
81
|
+
|
|
82
|
+
def submodule_files
|
|
83
|
+
dirs = Dir.glob(File.join(@root, ".git", "modules", "**", "HEAD")).map { |head| File.dirname(head) }
|
|
84
|
+
dirs = dirs.select { |dir| File.file?(File.join(dir, "config")) }
|
|
85
|
+
dirs.flat_map { |dir| GIT_FILES.map { |name| File.join(dir, name) } }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def worktrees = Dir.glob(File.join(@root, Project::WORKTREES_DIR, "*"))
|
|
89
|
+
|
|
90
|
+
def parents
|
|
91
|
+
protected.flat_map { |path| parents_of(path) }.uniq.sort_by(&:size)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def parents_of(path)
|
|
97
|
+
top = (dirs + harness_dirs).select { |dir| path.start_with?("#{dir}/") }.max_by(&:size)
|
|
98
|
+
top ? ancestors(path).take_while { |dir| dir.size >= top.size } : []
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def writable?(path)
|
|
102
|
+
real_path = real([path]).first
|
|
103
|
+
dirs.any? { |dir| real_path.start_with?("#{dir}/") }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def ancestors(path) = (dir = File.dirname(path)) == path ? [] : [dir, *ancestors(dir)]
|
|
107
|
+
def real(paths) = paths.compact.reject(&:empty?).map { |path| resolve(File.expand_path(path)) }.uniq
|
|
108
|
+
|
|
109
|
+
def resolve(path)
|
|
110
|
+
File.realpath(path)
|
|
111
|
+
rescue SystemCallError
|
|
112
|
+
File.exist?(File.dirname(path)) ? in_real_dir(path) : path
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def links(paths)
|
|
116
|
+
symlinks = paths.map { |path| File.expand_path(path) }.select { |path| File.symlink?(path) }
|
|
117
|
+
symlinks.map { |link| in_real_dir(link) }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def in_real_dir(path) = File.join(File.realpath(File.dirname(path)), File.basename(path))
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# maf shared library - code that the maf CLI and the scripts in .maf/bin share.
|
|
4
|
+
# The installer copies this folder to .maf/lib/maf/shared/. maf update replaces it.
|
|
5
|
+
# Use the Ruby stdlib only: a script loads this file without a gem bundle.
|
|
6
|
+
require "json"
|
|
7
|
+
|
|
8
|
+
module Maf
|
|
9
|
+
module Shared
|
|
10
|
+
module Sandbox
|
|
11
|
+
# References finds the files that a config file of a harness names.
|
|
12
|
+
module References
|
|
13
|
+
# A path in a command or a setting: ~/x, $HOME/x, ${HOME}/x, or /x.
|
|
14
|
+
PATH = %r{(?:~|\$\{?HOME\}?)?/[^\s"'`;|&()<>,\\]+}
|
|
15
|
+
# A relative path with a folder (scripts/hook.sh). A hook runs in the project folder.
|
|
16
|
+
RELATIVE = /(?<![\w.\/~}$-])[\w.-]+(?:\/[\w.-]+)+/
|
|
17
|
+
# An import in a Markdown file: @path, at the start of a line or after a space.
|
|
18
|
+
# A period or a comma after the path ends the sentence.
|
|
19
|
+
IMPORT = /(?:^|\s)@([^\s]+?)[.,;:!?)]*(?=\s|$)/
|
|
20
|
+
# Claude Code sets this variable to the project folder for a hook. It becomes ".", so RELATIVE finds the path.
|
|
21
|
+
PROJECT_DIR = /\$\{?CLAUDE_PROJECT_DIR\}?/
|
|
22
|
+
DEPTH = 5
|
|
23
|
+
MAX_SIZE = 1_000_000
|
|
24
|
+
|
|
25
|
+
def self.of(files, root, keep) = walk(files, root, DEPTH).select(&keep)
|
|
26
|
+
|
|
27
|
+
def self.walk(files, root, depth)
|
|
28
|
+
found = files.flat_map { |file| named_in(file, root) }.uniq - files
|
|
29
|
+
depth.zero? || found.empty? ? found : (found + walk(found, root, depth - 1)).uniq
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.named_in(file, root)
|
|
33
|
+
text = read(file)
|
|
34
|
+
paths = file.end_with?(".md") ? imports(text, file) : commands(text, file, root)
|
|
35
|
+
paths.select { |path| File.file?(path) }
|
|
36
|
+
rescue SystemCallError, ArgumentError
|
|
37
|
+
[]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.imports(text, file) = text.scan(IMPORT).flatten.map { |path| expand(path, file) }
|
|
41
|
+
|
|
42
|
+
def self.commands(text, file, root)
|
|
43
|
+
text = text.gsub(PROJECT_DIR, ".")
|
|
44
|
+
text.scan(PATH).map { |path| expand(path, file) } +
|
|
45
|
+
text.scan(RELATIVE).map { |path| File.expand_path(path, root) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.read(file)
|
|
49
|
+
text = File.file?(file) && File.size(file) < MAX_SIZE ? File.read(file) : ""
|
|
50
|
+
file.end_with?(".json") ? without_permissions(text) : text
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.without_permissions(text)
|
|
54
|
+
data = JSON.parse(text)
|
|
55
|
+
data.is_a?(Hash) ? JSON.generate(data.except("permissions")) : text
|
|
56
|
+
rescue JSON::ParserError
|
|
57
|
+
text
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.expand(path, file) = File.expand_path(path.sub(/\A\$\{?HOME\}?/, "~"), File.dirname(file))
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# maf shared library - code that the maf CLI and the scripts in .maf/bin share.
|
|
4
|
+
# The installer copies this folder to .maf/lib/maf/shared/. maf update replaces it.
|
|
5
|
+
# Use the Ruby stdlib only: a script loads this file without a gem bundle.
|
|
6
|
+
require "digest"
|
|
7
|
+
|
|
8
|
+
module Maf
|
|
9
|
+
module Shared
|
|
10
|
+
module Sandbox
|
|
11
|
+
# Seatbelt runs a command in a macOS sandbox-exec profile.
|
|
12
|
+
module Seatbelt
|
|
13
|
+
COMMAND = "/usr/bin/sandbox-exec"
|
|
14
|
+
DEVICES = '(literal "/dev/null") (literal "/dev/zero") (literal "/dev/dtracehelper") ' \
|
|
15
|
+
'(regex #"^/dev/tty") (regex #"^/dev/fd/") (regex #"^/dev/ptmx")'
|
|
16
|
+
DNS = '(path-literal "/private/var/run/mDNSResponder")'
|
|
17
|
+
TMP_PREFIX = "maf-sandbox-"
|
|
18
|
+
SERVICES = "(deny mach-lookup (global-name-regex #\"^com\\.apple\\.(coreservices|lsd)\\.\"))\n" \
|
|
19
|
+
"(deny appleevent-send)\n"
|
|
20
|
+
|
|
21
|
+
def self.wrap(cmd, paths)
|
|
22
|
+
tmp = private_tmp(paths.root)
|
|
23
|
+
[COMMAND, "-p", profile(paths, tmp), "env", "TMPDIR=#{tmp}/", "XDG_CACHE_HOME=#{tmp}/cache", *cmd]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.available? = File.executable?(COMMAND)
|
|
27
|
+
def self.require! = available? || abort("maf: sandbox mode seatbelt needs #{COMMAND} (macOS only).")
|
|
28
|
+
|
|
29
|
+
def self.private_tmp(root) = own_dir(project_tmp(root))
|
|
30
|
+
|
|
31
|
+
def self.project_tmp(root)
|
|
32
|
+
File.join(File.realpath("/tmp"), "#{TMP_PREFIX}#{Process.uid}-#{Digest::SHA256.hexdigest(root)[0, 12]}")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.own_dir(dir)
|
|
36
|
+
make(dir)
|
|
37
|
+
stat = File.lstat(dir)
|
|
38
|
+
return dir if stat.directory? && stat.uid == Process.uid && stat.mode.nobits?(0o077)
|
|
39
|
+
|
|
40
|
+
abort "maf: #{dir} belongs to another user or is open to others. Remove it."
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.make(dir)
|
|
44
|
+
Dir.mkdir(dir, 0o700)
|
|
45
|
+
rescue Errno::EEXIST
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.profile(paths, tmp)
|
|
50
|
+
"(version 1)\n(allow default)\n(deny file-write*)\n#{writes(paths)}#{protects(paths)}" \
|
|
51
|
+
"#{sockets([*paths.sockets, tmp])}#{SERVICES}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.writes(paths)
|
|
55
|
+
rules = paths.dirs.map { |dir| "(subpath #{quote(dir)})" } +
|
|
56
|
+
paths.prefixes.map { |prefix| "(prefix #{quote(prefix)})" } +
|
|
57
|
+
paths.state.filter_map { |pattern| regex(pattern) }
|
|
58
|
+
"(allow file-write* #{DEVICES}\n #{rules.join("\n ")})\n"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.regex(pattern)
|
|
62
|
+
return if pattern.include?('"')
|
|
63
|
+
|
|
64
|
+
"(regex #\"^#{escape(pattern).gsub("*", "[^/]*")}(/|$)\")"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.escape(pattern) = pattern.gsub(/[.^$|()\[\]{}+?\\]/) { |char| "\\#{char}" }
|
|
68
|
+
|
|
69
|
+
# A later rule wins, so these denials come after the allowed folders.
|
|
70
|
+
def self.protects(paths)
|
|
71
|
+
rules = paths.protected.map { |path| "(subpath #{quote(path)})" } +
|
|
72
|
+
paths.parents.map { |dir| "(literal #{quote(dir)})" }
|
|
73
|
+
"(deny file-write*\n #{rules.join("\n ")})\n"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.sockets(dirs)
|
|
77
|
+
allowed = [DNS, *dirs.map { |dir| "(subpath #{quote(dir)})" }]
|
|
78
|
+
rules = allowed.map { |path| "(allow network-outbound (remote unix-socket #{path}))\n" }
|
|
79
|
+
"(deny network-outbound (remote unix-socket))\n#{rules.join}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.quote(path) = "\"#{path.gsub(/["\\]/) { |char| "\\#{char}" }}\""
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# maf shared library - code that the maf CLI and the scripts in .maf/bin share.
|
|
4
|
+
# The installer copies this folder to .maf/lib/maf/shared/. maf update replaces it.
|
|
5
|
+
# Use the Ruby stdlib only: a script loads this file without a gem bundle.
|
|
6
|
+
require "json"
|
|
7
|
+
require_relative "project"
|
|
8
|
+
require_relative "harness"
|
|
9
|
+
require_relative "sandbox/paths"
|
|
10
|
+
require_relative "sandbox/seatbelt"
|
|
11
|
+
require_relative "sandbox/bwrap"
|
|
12
|
+
require_relative "sandbox/links"
|
|
13
|
+
|
|
14
|
+
module Maf
|
|
15
|
+
module Shared
|
|
16
|
+
# Sandbox runs an agent command in the OS sandbox of the mode in .maf/sandbox.json.
|
|
17
|
+
module Sandbox
|
|
18
|
+
BACKENDS = { "seatbelt" => Seatbelt, "bwrap" => Bwrap }.freeze
|
|
19
|
+
MODES = ["off", "auto", *BACKENDS.keys].freeze
|
|
20
|
+
POLICY = File.join(".maf", "sandbox.json")
|
|
21
|
+
|
|
22
|
+
def self.policy
|
|
23
|
+
path = File.join(Project.root, POLICY)
|
|
24
|
+
return {} unless File.exist?(path)
|
|
25
|
+
|
|
26
|
+
JSON.parse(File.read(path)).tap { |data| data.is_a?(Hash) || abort("maf: #{POLICY} must hold a JSON object.") }
|
|
27
|
+
rescue SystemCallError, JSON::ParserError => e
|
|
28
|
+
abort "maf: cannot read #{POLICY}: #{e.message.lines.first.strip}. Fix the file, or delete it."
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.mode(policy = self.policy)
|
|
32
|
+
mode = ENV.fetch("MAF_SANDBOX", nil).to_s
|
|
33
|
+
mode = policy["mode"] || "off" if mode.empty?
|
|
34
|
+
abort "maf: unknown sandbox mode '#{mode}' (use #{MODES.join(", ")})" unless MODES.include?(mode)
|
|
35
|
+
(mode == "auto" ? native : mode).tap { |name| BACKENDS[name]&.require! }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.native = RUBY_PLATFORM.include?("darwin") ? "seatbelt" : "bwrap"
|
|
39
|
+
|
|
40
|
+
def self.wrap(cmd, harness, coord_dir: ENV.fetch("COORD_DIR", nil))
|
|
41
|
+
policy = self.policy
|
|
42
|
+
mode = mode(policy)
|
|
43
|
+
mode == "off" ? cmd : BACKENDS[mode].wrap(harness.sandboxed(cmd), paths(harness, policy, coord_dir))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.keeping_links(harness, coord_dir: ENV.fetch("COORD_DIR", nil))
|
|
47
|
+
links = protected_links(harness, coord_dir)
|
|
48
|
+
yield
|
|
49
|
+
ensure
|
|
50
|
+
Links.restore(links || {})
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.protected_links(harness, coord_dir)
|
|
54
|
+
policy = self.policy
|
|
55
|
+
mode(policy) == "bwrap" && !Bwrap.inside? ? Links.record(paths(harness, policy, coord_dir).protected) : {}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.describe(harness, coord_dir: ENV.fetch("COORD_DIR", nil))
|
|
59
|
+
policy = self.policy
|
|
60
|
+
mode = mode(policy)
|
|
61
|
+
mode == "off" ? "off" : "#{mode}, #{writes(paths(harness, policy, coord_dir))}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.writes(paths)
|
|
65
|
+
state = paths.harness_dirs.select { |dir| File.exist?(dir) }
|
|
66
|
+
"writes to #{paths.existing.join(", ")}#{", and the state files in #{state.join(", ")}" if state.any?}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.paths(harness, policy, coord_dir)
|
|
70
|
+
protect_policy
|
|
71
|
+
Paths.new([harness, *project_harnesses].uniq, Project.root, coord_dir, Array(policy["write"]))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.project_harnesses = Project.manifest.fetch("agents", []).filter_map { |a| Harness[a["harness"]] }
|
|
75
|
+
|
|
76
|
+
def self.protect_policy
|
|
77
|
+
path = File.join(Project.root, POLICY)
|
|
78
|
+
File.write(path, "{}\n") unless File.exist?(path)
|
|
79
|
+
rescue SystemCallError
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
data/lib/maf/team_command.rb
CHANGED
|
@@ -32,15 +32,15 @@ module Maf
|
|
|
32
32
|
|
|
33
33
|
def line(id, entry)
|
|
34
34
|
mode = entry["dispatch"] ? "dispatch" : "interactive"
|
|
35
|
-
[id.ljust(28), entry["role"].to_s.ljust(20), entry["harness"], entry["model"] || "-", mode, state(entry)]
|
|
35
|
+
[id.ljust(28), entry["role"].to_s.ljust(20), entry["harness"], entry["model"] || "-", mode, state(id, entry)]
|
|
36
36
|
.join(" ")
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
-
def state(entry)
|
|
39
|
+
def state(id, entry)
|
|
40
40
|
pid = entry["pid"]
|
|
41
41
|
return "-" unless pid
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
Workers.at(@root).running?(id) ? "running (pid #{pid})" : "stopped"
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
# The budget keys replace the old budget. Keep every other manifest key,
|
|
@@ -2,25 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
module Maf
|
|
4
4
|
module Uninstall
|
|
5
|
-
# .maf/config.json lists the generated agents.
|
|
6
|
-
# the project, so only the manifest tells which ones belong to it.
|
|
5
|
+
# .maf/config.json lists the generated agents. Some harnesses keep role
|
|
6
|
+
# files outside the project, so only the manifest tells which ones belong to it.
|
|
7
7
|
class Manifest
|
|
8
8
|
def initialize(project)
|
|
9
9
|
@project = project
|
|
10
10
|
@path = File.join(project, ".maf/config.json")
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
-
def steps = File.exist?(
|
|
13
|
+
def steps = [@path, policy].select { |path| File.exist?(path) }.map { |path| Owned.remove(path) }
|
|
14
|
+
def policy = File.join(@project, Maf::Shared::Sandbox::POLICY)
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
# The role files of the harnesses that keep them outside .maf/agents/.
|
|
17
|
+
def outside_role_files
|
|
18
|
+
data.fetch("agents", []).filter_map do |agent|
|
|
19
|
+
harness = Maf::Shared::Harness[agent["harness"]]
|
|
20
|
+
harness.role_destination(@project, agent["role"], data) if harness && !harness.role_path(agent["role"])
|
|
21
|
+
end
|
|
18
22
|
end
|
|
19
23
|
|
|
20
24
|
private
|
|
21
25
|
|
|
22
|
-
def hermes_dir = data.fetch("hermes_dir", Flow::DEFAULT_HERMES_DIR)
|
|
23
|
-
|
|
24
26
|
def data
|
|
25
27
|
@data ||= File.exist?(@path) ? (JSON.parse(File.read(@path)) rescue {}) : {}
|
|
26
28
|
end
|