maf 0.1.0 → 0.3.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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +174 -0
  3. data/README.md +29 -11
  4. data/assets/agents-contract.md +2 -0
  5. data/assets/analyst +29 -51
  6. data/assets/coord +168 -41
  7. data/assets/dashboard +1 -5
  8. data/assets/dispatcher +127 -298
  9. data/assets/harness-hooks/context-watch.rb +5 -4
  10. data/assets/lima/maf.yaml.erb +61 -0
  11. data/install.md +6 -1
  12. data/lib/maf/bootstrap/claude_settings.rb +17 -9
  13. data/lib/maf/bootstrap/script_planner.rb +1 -1
  14. data/lib/maf/bootstrap.rb +4 -0
  15. data/lib/maf/cli.rb +22 -9
  16. data/lib/maf/flow/agent_links.rb +2 -2
  17. data/lib/maf/flow/bootstrapper.rb +5 -5
  18. data/lib/maf/flow/codex_hooks.rb +12 -1
  19. data/lib/maf/flow/effort_report.rb +20 -0
  20. data/lib/maf/flow/generator.rb +3 -2
  21. data/lib/maf/flow/harness_linker.rb +2 -2
  22. data/lib/maf/flow/hermes_env_setup.rb +46 -0
  23. data/lib/maf/flow/hook_files.rb +6 -0
  24. data/lib/maf/flow/hook_installer.rb +6 -20
  25. data/lib/maf/flow/manifest.rb +3 -3
  26. data/lib/maf/flow/mcp_config.rb +8 -22
  27. data/lib/maf/flow/mcp_installer.rb +3 -3
  28. data/lib/maf/flow/models.rb +14 -38
  29. data/lib/maf/flow/options.rb +33 -5
  30. data/lib/maf/flow/prompt_builder.rb +7 -14
  31. data/lib/maf/flow/prompt_text.rb +13 -14
  32. data/lib/maf/flow/report.rb +8 -9
  33. data/lib/maf/flow/role_catalog.rb +1 -1
  34. data/lib/maf/flow/role_files.rb +7 -22
  35. data/lib/maf/flow/roster.rb +6 -3
  36. data/lib/maf/flow/validator.rb +2 -2
  37. data/lib/maf/flow.rb +7 -21
  38. data/lib/maf/menu.rb +49 -15
  39. data/lib/maf/migrate/role_files.rb +3 -1
  40. data/lib/maf/retire.rb +42 -6
  41. data/lib/maf/setup_agent/args.rb +16 -10
  42. data/lib/maf/setup_agent/dispatch.rb +3 -3
  43. data/lib/maf/setup_agent/launcher.rb +16 -65
  44. data/lib/maf/setup_agent/manifest.rb +9 -3
  45. data/lib/maf/setup_agent/pty_relay.rb +75 -0
  46. data/lib/maf/setup_agent/role_file.rb +1 -1
  47. data/lib/maf/setup_agent/runtime_hooks.rb +1 -12
  48. data/lib/maf/setup_agent.rb +11 -13
  49. data/lib/maf/shared/harness/base.rb +57 -0
  50. data/lib/maf/shared/harness/defaults.rb +147 -0
  51. data/lib/maf/shared/harness/output.rb +55 -0
  52. data/lib/maf/shared/harness/traits.rb +35 -0
  53. data/lib/maf/shared/harness/vm_credential.rb +36 -0
  54. data/lib/maf/shared/harness.rb +12 -0
  55. data/lib/maf/shared/harnesses/claude.rb +137 -0
  56. data/lib/maf/shared/harnesses/codex.rb +202 -0
  57. data/lib/maf/shared/harnesses/cursor.rb +120 -0
  58. data/lib/maf/shared/harnesses/hermes.rb +122 -0
  59. data/lib/maf/shared/harnesses/opencode.rb +192 -0
  60. data/lib/maf/shared/processes.rb +20 -0
  61. data/lib/maf/shared/roles.rb +4 -0
  62. data/lib/maf/shared/sandbox/bwrap.rb +128 -0
  63. data/lib/maf/shared/sandbox/harness_files.rb +50 -0
  64. data/lib/maf/shared/sandbox/links.rb +28 -0
  65. data/lib/maf/shared/sandbox/paths.rb +124 -0
  66. data/lib/maf/shared/sandbox/references.rb +64 -0
  67. data/lib/maf/shared/sandbox/seatbelt.rb +86 -0
  68. data/lib/maf/shared/sandbox.rb +84 -0
  69. data/lib/maf/team.rb +5 -4
  70. data/lib/maf/team_command.rb +5 -3
  71. data/lib/maf/uninstall/manifest.rb +10 -8
  72. data/lib/maf/uninstall/mcp_entries.rb +4 -5
  73. data/lib/maf/uninstall/notes.rb +13 -2
  74. data/lib/maf/uninstall/role_files.rb +11 -10
  75. data/lib/maf/uninstall/runner.rb +4 -3
  76. data/lib/maf/uninstall/scripts.rb +1 -1
  77. data/lib/maf/uninstall/vm_files.rb +16 -0
  78. data/lib/maf/uninstall.rb +20 -5
  79. data/lib/maf/untrack.rb +12 -25
  80. data/lib/maf/version.rb +1 -1
  81. data/lib/maf/vm/login.rb +52 -0
  82. data/lib/maf/vm/options.rb +41 -0
  83. data/lib/maf/vm/template.rb +44 -0
  84. data/lib/maf/vm.rb +94 -0
  85. data/lib/maf/worker_control.rb +16 -8
  86. data/lib/maf/worker_list.rb +83 -0
  87. data/lib/maf/workers.rb +27 -0
  88. data/templates/roles.yml +14 -0
  89. metadata +33 -11
  90. data/lib/maf/setup_agent/hermes_launcher.rb +0 -34
  91. data/lib/maf/setup_agent/hermes_skill.rb +0 -26
  92. data/templates/claude.md.erb +0 -16
  93. data/templates/codex.md.erb +0 -7
  94. data/templates/hermes.md.erb +0 -12
  95. data/templates/opencode.md.erb +0 -24
@@ -0,0 +1,122 @@
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 "../harness/traits"
7
+ require_relative "../roles"
8
+
9
+ module Maf
10
+ module Shared
11
+ module Harnesses
12
+ # Hermes loads role files as skills from <hermes_dir>/<project>-<role>/,
13
+ # outside the project. `--skills <project>-<role>` loads one. Hermes has
14
+ # no agent flag and cannot be woken, so an idle agent blocks in
15
+ # `coord next --wait`. A role with can_edit false starts without the
16
+ # file-writing toolsets. No role gets the kanban or delegation
17
+ # toolset. Hermes keeps hooks and MCP servers in global
18
+ # files, so maf prints the commands that add them.
19
+ class Hermes < Harness::Base
20
+ include Harness::ResultEvent
21
+
22
+ DEFAULT_DIR = File.join(Dir.home, ".hermes", "skills")
23
+ MAX_TURNS = 50
24
+
25
+ def sandbox_dirs = [File.join(Dir.home, ".hermes")]
26
+ PROTECTED = %w[config.yaml .env shell_init.sh SOUL.md hooks agent-hooks shell-hooks-allowlist.json bin scripts
27
+ cron skills plugins hermes-agent].freeze
28
+ def sandbox_protected = in_dir(File.join(Dir.home, ".hermes"), PROTECTED)
29
+ STATE = %w[sessions logs cache memories state *.db* pastes composer-pastes image_cache audio_cache locks
30
+ pending runtime telemetry terminal-sessions processes.json* spawn-ledger.json* auth.json* auth.lock
31
+ google_token.json* models_dev_cache* provider_models_cache.json* context_length_cache.yaml*
32
+ .hermes_history* .*lock*].freeze
33
+ def sandbox_state = in_dir(File.join(Dir.home, ".hermes"), STATE)
34
+ def sandbox_placeholders = super - [File.join(Dir.home, ".hermes", "hermes-agent")]
35
+ def vm_credentials = [Harness::VmCredential.file(File.join(Dir.home, ".hermes", ".env"), ".hermes/.env")]
36
+
37
+ def role_destination(project, role, settings = {})
38
+ File.join(settings["hermes_dir"] || DEFAULT_DIR, "#{File.basename(project)}-#{role}", "SKILL.md")
39
+ end
40
+
41
+ def role_template = <<~ERB
42
+ ---
43
+ # >>> multi-agent-flow >>>
44
+ name: <%= role %>
45
+ description: <%= description %>
46
+ ---
47
+
48
+ # <%= title %>
49
+
50
+ <% if model -%>
51
+ > Model: `<%= model %>` (Hermes sets the model with `hermes model`)
52
+ <% end -%>
53
+ <%= prompt %>
54
+ ERB
55
+
56
+ def model_hint(_model) = "hermes model"
57
+ EFFORTS = %w[none minimal low medium high xhigh max ultra].freeze
58
+ def efforts(_model = nil) = EFFORTS
59
+ def effort_flag(effort) = super(effort, "--reasoning")
60
+ def mcp_command(server, vault) = "hermes mcp add #{server} --command ruby --args #{vault} mcp"
61
+ def install_hooks(_project) = Flow::HermesHookSetup.new.install | Flow::HermesEnvSetup.new.install
62
+ def global_files = [File.join(Dir.home, ".hermes", "agent-hooks", "next-task.sh")]
63
+
64
+ # The skill name uses the main checkout's name, from the project root
65
+ # and from a worktree alike.
66
+ def skill_name(role) = "#{File.basename(Project.root)}-#{role}"
67
+
68
+ # The skill that maf add generated for the role, or nil.
69
+ def skill(role)
70
+ dir = Project.manifest.fetch("hermes_dir", DEFAULT_DIR)
71
+ File.exist?(File.join(dir, skill_name(role), "SKILL.md")) ? skill_name(role) : nil
72
+ end
73
+
74
+ def read_only?(role)
75
+ Project.manifest.fetch("agents", []).any? { |agent| agent["role"] == role && agent["can_edit"] == false }
76
+ end
77
+
78
+ # With -t, Hermes loads an MCP server only when the list names its toolset.
79
+ def tools(role)
80
+ toolsets = read_only?(role) ? Roles::READ_ONLY_TOOLSETS : Roles::EDIT_TOOLSETS
81
+ ["-t", "#{toolsets},mcp-graphify-#{File.basename(Project.root)}"]
82
+ end
83
+
84
+ PROMPT = "Follow the coordination rules of your role skill as role %<role>s. " \
85
+ "Run coord inbox, then coord next --wait. Claim a task, do the work, finish it. Repeat."
86
+ LEAD_PROMPT = "Follow your role skill and its coordination rules as role %<role>s. " \
87
+ "You are a lead role. Never claim a task. Run coord inbox --wait and handle each message."
88
+
89
+ # `-q` on a TTY seeds an interactive session with the prompt.
90
+ def launch(role, model, effort = nil)
91
+ prompt = format(Roles::LEADS.include?(role) ? LEAD_PROMPT : PROMPT, role: role)
92
+ ["hermes", "chat", *skill(role)&.then { |name| ["--skills", name] }, *model_flag(model), *effort_flag(effort),
93
+ *tools(role), "-q", prompt]
94
+ end
95
+
96
+ # --no-skill drops the skill. Without --skill, the generated skill of the role loads.
97
+ def prepare(config)
98
+ config.skill = nil if config.no_skill
99
+ config.skill ||= skill(config.role) unless config.no_skill
100
+ config.max_turns ||= MAX_TURNS
101
+ config
102
+ end
103
+
104
+ # `hermes chat --oneshot` with JSONL output and the skill preloaded.
105
+ def build_command(config, prompt, session_id)
106
+ cmd = %w[hermes chat --oneshot --yolo --format stream-json -Q]
107
+ cmd += run_flags(config)
108
+ cmd += ["--max-turns", config.max_turns.to_s]
109
+ cmd + resume(session_id, "--resume") + ["-q", prompt]
110
+ end
111
+
112
+ def run_flags(config)
113
+ [*(["--skills", config.skill] if config.skill), *model_flag(config.model), *effort_flag(config.effort),
114
+ *tools(config.role)]
115
+ end
116
+
117
+ def stale_session?(output) = output.include?("Session not found")
118
+ def result_text(output) = result_event(output)["text"]
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,192 @@
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 "open3"
7
+ require_relative "../harness/traits"
8
+
9
+ module Maf
10
+ module Shared
11
+ module Harnesses
12
+ # OpencodeSandbox holds the sandbox paths of opencode.
13
+ module OpencodeSandbox
14
+ DATA_DIRS = %w[.local/share/opencode .local/state/opencode .config/opencode].freeze
15
+ def sandbox_dirs = DATA_DIRS.map { |dir| File.join(Dir.home, dir) }
16
+ PROTECTED = %w[opencode.json opencode.jsonc config.json package.json node_modules plugin plugins agent agents
17
+ command commands skills AGENTS.md].freeze
18
+ def sandbox_protected = in_dir(sandbox_dirs.last, PROTECTED)
19
+ STATE = %w[auth.json* log opencode.db* repos snapshot tool-output storage project].freeze
20
+ def sandbox_state = [*in_dir(sandbox_dirs.first, STATE), File.join(sandbox_dirs[1], "*")]
21
+ def sandbox_project = %w[opencode.json opencode.jsonc .opencode]
22
+ end
23
+
24
+ # opencode. `--agent` loads .opencode/agents/<role>.md. The board-watch
25
+ # plugin in .opencode/plugins/ wakes an idle session. opencode merges the
26
+ # file in OPENCODE_CONFIG with the project's opencode.json.
27
+ class Opencode < Harness::Base
28
+ include OpencodeSandbox
29
+
30
+ def role_dir = ".opencode/agents"
31
+
32
+ def role_template = <<~ERB
33
+ ---
34
+ # >>> multi-agent-flow >>>
35
+ description: <%= description %>
36
+ mode: all
37
+ <% if model -%>
38
+ model: <%= model %>
39
+ <% end -%>
40
+ <% if effort -%>
41
+ variant: <%= effort %>
42
+ <% end -%>
43
+ temperature: 0.1
44
+ permission:
45
+ edit: <%= can_edit ? "allow" : "deny" %>
46
+ bash: allow
47
+ # Worktrees live inside the project. The board, the artifacts, and git
48
+ # data are outside the worktree, so opencode would ask for each access.
49
+ external_directory:
50
+ "/tmp/*": allow
51
+ "/private/tmp/*": allow
52
+ "<%= project %>/.maf/*": allow
53
+ "<%= project %>/.git/*": allow
54
+ task:
55
+ "*": deny
56
+ explore: allow
57
+ ---
58
+
59
+ <%= prompt %>
60
+ ERB
61
+
62
+ def models
63
+ return nil unless installed?
64
+
65
+ out, status = Open3.capture2("opencode", "models", err: File::NULL)
66
+ status.success? && !out.strip.empty? ? out.split : nil
67
+ rescue SystemCallError
68
+ nil
69
+ end
70
+
71
+ def wait = :watch
72
+
73
+ def vm_install = "curl -fsSL https://opencode.ai/install | bash"
74
+ AUTH = ".local/share/opencode/auth.json"
75
+ def vm_credentials = [Harness::VmCredential.file(File.join(Dir.home, AUTH), AUTH)]
76
+
77
+ def mcp
78
+ { file: ".maf/mcp/opencode.json", key: "mcp", schema: "https://opencode.ai/config.json",
79
+ entry: { "type" => "local", "command" => Harness::VAULT_MCP, "enabled" => true } }
80
+ end
81
+
82
+ def legacy_mcp_file = "opencode.json"
83
+ def bootstrap_dir = ".opencode"
84
+
85
+ PLUGIN = ".opencode/plugins/board-watch.js"
86
+
87
+ def runtime_hooks(worktree) = SetupAgent::RuntimeHooks.copy(worktree, "board-watch-opencode.js", PLUGIN)
88
+ def runtime_files = [PLUGIN]
89
+ def empty_dirs = [role_dir, File.dirname(PLUGIN), home_dir]
90
+
91
+ # An older maf tracked the role files and the plugin.
92
+ def untrack_paths(_root, tracked) = tracked.call(role_dir) + tracked.call(PLUGIN)
93
+
94
+ def launch_env = config_env(flow_file("mcp", "opencode.json"))
95
+ def run_env(config) = config_env(run_file(config, "mcp", "opencode.json"))
96
+ def config_env(file) = File.exist?(file) ? { "OPENCODE_CONFIG" => file } : {}
97
+
98
+ # The TUI has no variant flag. It reads the variant from the agent file.
99
+ def launch(role, model, effort = nil)
100
+ OpencodeVariant.warn_unused(File.join(role_dir, "#{role}.md"), role, effort)
101
+ ["opencode", ".", "--agent", role, "--prompt", "Start your work loop now.", *model_flag(model)]
102
+ end
103
+
104
+ def effort_flag(effort) = super(effort, "--variant")
105
+
106
+ # `opencode run --format json`. The result event shape is not verified,
107
+ # so the log shows no result text.
108
+ def build_command(config, prompt, session_id)
109
+ %w[opencode run --format json --agent] + [config.role] + model_flag(config.model, "-m") +
110
+ effort_flag(config.effort) + resume(session_id, "--session") + [prompt]
111
+ end
112
+
113
+ # DeepSeek costs twice as much at peak hours.
114
+ def peak_rate?(config) = configured_model(config).to_s.include?("deepseek")
115
+
116
+ # The model of a run in the opencode order: --model, the agent file, the project config, the user config.
117
+ CONFIG_MODEL = /^\s*"model"\s*:\s*"([^"]+)"/
118
+ AGENT_MODEL = /\A---\n(?:(?!^---$).)*?^model:\s*(\S+)/m
119
+
120
+ def configured_model(config)
121
+ config.model || find(File.join(role_dir, "#{config.role}.md"), AGENT_MODEL) ||
122
+ config_paths.lazy.filter_map { |path| find(path, CONFIG_MODEL) }.first
123
+ end
124
+
125
+ def config_paths
126
+ home = File.join(ENV.fetch("XDG_CONFIG_HOME", File.join(Dir.home, ".config")), "opencode")
127
+ %w[opencode.json opencode.jsonc] + %w[opencode.jsonc opencode.json].map { |name| File.join(home, name) }
128
+ end
129
+
130
+ def find(path, pattern) = File.exist?(path) ? File.read(path)[pattern, 1] : nil
131
+
132
+ # opencode prints terminal escape codes and can glue two JSON objects
133
+ # onto one line, so the session ID is matched on the start of an event
134
+ # object (captured from a real run), not by parsing whole lines.
135
+ EVENT_SESSION = /\{"type":"[\w.-]+","timestamp":\d+,"sessionID":"([^"]+)"/
136
+ # Each model call ends with a step_finish event. Its tokens.input
137
+ # excludes the cache reads and writes; reasoning is billed as output.
138
+ STEP_FINISH = /\{"type":"step_finish".*/
139
+
140
+ def stale_session?(output) = output.include?("Session not found")
141
+ def session_id(output) = output.scan(EVENT_SESSION).flatten.last
142
+
143
+ def steps(output)
144
+ output.scan(STEP_FINISH).filter_map { |json| Harness::Events.parse(json)&.dig("part", "tokens") }
145
+ end
146
+
147
+ def usage(output) = Harness::Usage.sum(steps(output).map { |tokens| Harness::Usage.normalize(flat(tokens)) })
148
+ def context(output) = steps(output).last&.then { |tokens| Harness::Usage.input(flat(tokens)) }
149
+
150
+ def flat(tokens)
151
+ { "input_tokens" => tokens["input"].to_i, "cache_read_input_tokens" => tokens.dig("cache", "read").to_i,
152
+ "cache_creation_input_tokens" => tokens.dig("cache", "write").to_i,
153
+ "output_tokens" => tokens["output"].to_i + tokens["reasoning"].to_i }
154
+ end
155
+
156
+ # opencode keeps its sessions in a SQLite database. The sqlite3 CLI reads it.
157
+ def database = File.join(Dir.home, ".local", "share", "opencode", "opencode.db")
158
+
159
+ def transcript_calls(id)
160
+ return [] unless File.exist?(database) && id.match?(/\A[\w-]+\z/)
161
+
162
+ out, status = Open3.capture2("sqlite3", "-readonly", "-json", database, transcript_query(id))
163
+ status.success? && !out.empty? ? JSON.parse(out).map(&:values) : []
164
+ rescue SystemCallError, JSON::ParserError
165
+ []
166
+ end
167
+
168
+ def transcript_query(id)
169
+ "select json_extract(data,'$.tool') tool, json_extract(data,'$.state.input.command') command, " \
170
+ "length(json_extract(data,'$.state.output')) size from part where session_id = '#{id}' " \
171
+ "and json_extract(data,'$.type') = 'tool'"
172
+ end
173
+ end
174
+
175
+ # OpencodeVariant tells the user how to change the level of an interactive session.
176
+ module OpencodeVariant
177
+ PATTERN = /\A---\n(?:(?!^---$).)*?^variant:\s*(\S+)/m
178
+ HINT = "maf: the opencode TUI cannot take --effort %<effort>s. The session starts with %<current>s. " \
179
+ "In the session, press ctrl+t to pick the variant %<effort>s. If the agent is in its work " \
180
+ "loop, stop it (Esc) and restart the loop: \"Start your work loop now.\" " \
181
+ "To keep the level for each session: maf add opencode:%<role>s --effort %<role>s=%<effort>s"
182
+
183
+ def self.warn_unused(path, role, effort)
184
+ current = File.exist?(path) ? File.read(path)[PATTERN, 1] : nil
185
+ return if effort.nil? || effort == current
186
+
187
+ warn format(HINT, effort: effort, role: role, current: current ? "variant #{current}" : "the default variant")
188
+ end
189
+ end
190
+ end
191
+ end
192
+ end
@@ -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
@@ -14,6 +14,10 @@ module Maf
14
14
  # Hermes toolsets for a role with can_edit false: no file, code_execution,
15
15
  # or delegation toolset. The shell stays, because the role needs coord and git.
16
16
  READ_ONLY_TOOLSETS = "terminal,web,skills,todo,memory,session_search,clarify"
17
+
18
+ # Hermes toolsets for a role that edits. coord is the only task board and
19
+ # maf starts the workers, so kanban, delegation, and cronjob stay off.
20
+ EDIT_TOOLSETS = "#{READ_ONLY_TOOLSETS},file,code_execution,browser,vision".freeze
17
21
  end
18
22
  end
19
23
  end
@@ -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