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.
Files changed (81) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +131 -0
  3. data/README.md +11 -10
  4. data/assets/analyst +29 -51
  5. data/assets/coord +23 -20
  6. data/assets/dashboard +1 -5
  7. data/assets/dispatcher +69 -286
  8. data/assets/harness-hooks/context-watch.rb +5 -4
  9. data/assets/lima/maf.yaml.erb +61 -0
  10. data/lib/maf/bootstrap/script_planner.rb +1 -1
  11. data/lib/maf/cli.rb +3 -0
  12. data/lib/maf/flow/agent_links.rb +2 -2
  13. data/lib/maf/flow/bootstrapper.rb +5 -5
  14. data/lib/maf/flow/codex_hooks.rb +12 -1
  15. data/lib/maf/flow/generator.rb +1 -1
  16. data/lib/maf/flow/harness_linker.rb +2 -2
  17. data/lib/maf/flow/hook_files.rb +6 -0
  18. data/lib/maf/flow/hook_installer.rb +6 -20
  19. data/lib/maf/flow/manifest.rb +2 -2
  20. data/lib/maf/flow/mcp_config.rb +8 -22
  21. data/lib/maf/flow/mcp_installer.rb +3 -3
  22. data/lib/maf/flow/models.rb +6 -41
  23. data/lib/maf/flow/options.rb +4 -2
  24. data/lib/maf/flow/prompt_builder.rb +7 -14
  25. data/lib/maf/flow/prompt_text.rb +7 -11
  26. data/lib/maf/flow/report.rb +2 -4
  27. data/lib/maf/flow/role_files.rb +4 -19
  28. data/lib/maf/flow/validator.rb +2 -2
  29. data/lib/maf/flow.rb +4 -20
  30. data/lib/maf/menu.rb +1 -1
  31. data/lib/maf/migrate/role_files.rb +3 -1
  32. data/lib/maf/retire.rb +42 -6
  33. data/lib/maf/setup_agent/dispatch.rb +1 -1
  34. data/lib/maf/setup_agent/launcher.rb +15 -66
  35. data/lib/maf/setup_agent/pty_relay.rb +75 -0
  36. data/lib/maf/setup_agent/role_file.rb +1 -1
  37. data/lib/maf/setup_agent/runtime_hooks.rb +1 -12
  38. data/lib/maf/setup_agent.rb +1 -3
  39. data/lib/maf/shared/harness/base.rb +57 -0
  40. data/lib/maf/shared/harness/defaults.rb +140 -0
  41. data/lib/maf/shared/harness/output.rb +55 -0
  42. data/lib/maf/shared/harness/traits.rb +35 -0
  43. data/lib/maf/shared/harness/vm_credential.rb +36 -0
  44. data/lib/maf/shared/harness.rb +12 -0
  45. data/lib/maf/shared/harnesses/claude.rb +134 -0
  46. data/lib/maf/shared/harnesses/codex.rb +173 -0
  47. data/lib/maf/shared/harnesses/cursor.rb +64 -0
  48. data/lib/maf/shared/harnesses/hermes.rb +112 -0
  49. data/lib/maf/shared/harnesses/opencode.rb +169 -0
  50. data/lib/maf/shared/processes.rb +20 -0
  51. data/lib/maf/shared/sandbox/bwrap.rb +128 -0
  52. data/lib/maf/shared/sandbox/harness_files.rb +50 -0
  53. data/lib/maf/shared/sandbox/links.rb +28 -0
  54. data/lib/maf/shared/sandbox/paths.rb +124 -0
  55. data/lib/maf/shared/sandbox/references.rb +64 -0
  56. data/lib/maf/shared/sandbox/seatbelt.rb +86 -0
  57. data/lib/maf/shared/sandbox.rb +84 -0
  58. data/lib/maf/team_command.rb +3 -3
  59. data/lib/maf/uninstall/manifest.rb +10 -8
  60. data/lib/maf/uninstall/mcp_entries.rb +4 -5
  61. data/lib/maf/uninstall/notes.rb +13 -2
  62. data/lib/maf/uninstall/role_files.rb +11 -10
  63. data/lib/maf/uninstall/runner.rb +4 -3
  64. data/lib/maf/uninstall/scripts.rb +1 -1
  65. data/lib/maf/uninstall/vm_files.rb +16 -0
  66. data/lib/maf/uninstall.rb +20 -5
  67. data/lib/maf/untrack.rb +6 -26
  68. data/lib/maf/version.rb +1 -1
  69. data/lib/maf/vm/login.rb +52 -0
  70. data/lib/maf/vm/options.rb +41 -0
  71. data/lib/maf/vm/template.rb +44 -0
  72. data/lib/maf/vm.rb +94 -0
  73. data/lib/maf/worker_control.rb +4 -7
  74. data/lib/maf/workers.rb +27 -0
  75. metadata +30 -11
  76. data/lib/maf/setup_agent/hermes_launcher.rb +0 -34
  77. data/lib/maf/setup_agent/hermes_skill.rb +0 -26
  78. data/templates/claude.md.erb +0 -16
  79. data/templates/codex.md.erb +0 -7
  80. data/templates/hermes.md.erb +0 -12
  81. data/templates/opencode.md.erb +0 -24
@@ -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
@@ -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
- Shared::Processes.alive?(pid) ? "running (pid #{pid})" : "stopped"
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. Hermes skills live outside
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?(@path) ? [Owned.remove(@path)] : []
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
- def hermes_skills
16
- roles = data.fetch("agents", []).select { |a| a["harness"] == "hermes" }.map { |a| a["role"] }
17
- roles.map { |role| File.join(hermes_dir, "#{File.basename(@project)}-#{role}", "SKILL.md") }
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