Add a bash tool for isolated chat workspaces

1245da661734 · Devin AI · · parent 78fc8bcda609

Add a bash tool for isolated chat workspaces

The model-facing bash tool runs a shell command inside the assigned
mutable workspace only: an emptied environment, a network namespace when
the host supports one, its own session and process group, bounded
timeouts, one command at a time per workspace, and a tail preview with
the full redacted output stored as a bounded-lifetime host artifact when
truncated. Execution requires the command.execute authority and refuses
canonical, read-only, and missing workspaces.

Closes #63.

Co-Authored-By: Christopher David <chris@openagents.com>
Co-Authored-By
Christopher David <chris@openagents.com>
Closes
#63

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified config/config.exs
  • modified lib/openagents/tools/conversation_execution_context.ex
  • modified lib/openagents/tools/runner.ex
  • added lib/openagents/tools/workspace_bash.ex
  • modified lib/openagents/tools/workspace_files.ex
  • added test/openagents/tools/workspace_bash_test.exs

Diff

6 files changed, +874 -1

config/config.exs modified +1

@@ -151,6 +151,7 @@ config :openagents,

151 151
    OpenAgents.Tools.WorkspaceRead,
152 152
    OpenAgents.Tools.WorkspaceWrite,
153 153
    OpenAgents.Tools.WorkspaceEdit,
154
    OpenAgents.Tools.WorkspaceBash,
154 155
    OpenAgents.Tools.PublishChanges,
155 156
    OpenAgents.Tools.OpenPullRequest,
156 157
    OpenAgents.Tools.ConversationSearch,
lib/openagents/tools/conversation_execution_context.ex modified +1

@@ -16,6 +16,7 @@ defmodule OpenAgents.Tools.ConversationExecutionContext do

16 16
  alias OpenAgents.Tools.ExecutionContext
17 17
18 18
  @authorities MapSet.new([
19
                 "command.execute",
19 20
                 "computer.control",
20 21
                 "conversation.read",
21 22
                 "github.read",
lib/openagents/tools/runner.ex modified +14

@@ -406,6 +406,20 @@ defmodule OpenAgents.Tools.Runner do

406 406
  defp error_message(:workspace_snapshot_root_invalid),
407 407
    do: "The host snapshot store must be outside the assigned workspace."
408 408
409
  defp error_message(:invalid_command), do: "The command is missing or invalid."
410
411
  defp error_message(:invalid_command_timeout),
412
    do: "The requested command timeout is invalid."
413
414
  defp error_message(:command_executor_unavailable),
415
    do: "This host cannot execute workspace commands."
416
417
  defp error_message(:command_concurrency_limit),
418
    do: "Another command is already running in this workspace."
419
420
  defp error_message(:workspace_artifact_failed),
421
    do: "The full command output could not be stored as a host artifact."
422
409 423
  defp error_message(:invalid_edits), do: "The edit batch is invalid."
410 424
  defp error_message(:overlapping_edits), do: "The edit batch contains overlapping matches."
411 425
lib/openagents/tools/workspace_bash.ex added +490

@@ -0,0 +1,490 @@

1
defmodule OpenAgents.Tools.WorkspaceBash do
2
  @moduledoc """
3
  Runs one bounded shell command inside an explicit, noncanonical agent
4
  workspace.
5
6
  The command never runs against the canonical application checkout or the
7
  Forge data directory: the workspace root passes the same fail-closed checks
8
  as the workspace file tools. The child process starts with an emptied
9
  environment, its own session and process group, and — where the host
10
  supports unprivileged network namespaces — no network access. Output is
11
  captured as one interleaved stdout/stderr stream, redacted, and bounded to
12
  the last #{2_000} lines or 50 KiB; when the preview is truncated the full
13
  redacted output is stored as a bounded-lifetime host artifact.
14
  """
15
16
  @behaviour OpenAgents.Tools.Tool
17
18
  alias OpenAgents.Modules.Metadata
19
  alias OpenAgents.Tools.{ExecutionResult, Redaction, Tool, WorkspaceFiles}
20
21
  @default_timeout_seconds 30
22
  @maximum_timeout_seconds 120
23
  @maximum_preview_lines 2_000
24
  @maximum_preview_bytes 50 * 1_024
25
  @maximum_captured_bytes 1_024 * 1_024
26
  @artifact_lifetime_seconds 24 * 60 * 60
27
  @path "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
28
29
  @impl true
30
  def specification do
31
    %Tool{
32
      module_id: "openagents.tool.workspace_bash.v1",
33
      name: "bash",
34
      version: 1,
35
      description:
36
        "Runs one shell command inside the assigned agent workspace and returns its exit " <>
37
          "status with bounded, interleaved output.",
38
      input_schema: %{
39
        "type" => "object",
40
        "properties" => %{
41
          "command" => %{"type" => "string", "maxLength" => 4_000},
42
          "timeout_seconds" => %{
43
            "type" => "integer",
44
            "minimum" => 1,
45
            "maximum" => @maximum_timeout_seconds
46
          }
47
        },
48
        "required" => ["command"],
49
        "additionalProperties" => false
50
      },
51
      output_schema: %{"type" => "object", "properties" => %{}, "additionalProperties" => true},
52
      side_effect: :reversible_write,
53
      required_scope: "browser_conversation",
54
      required_authority: "command.execute",
55
      executor: %{id: "openagents.workspace", disclosure: "the assigned agent workspace"},
56
      maintainer: "OpenAgents",
57
      attribution: ["OpenAgentsInc/openagents.com"],
58
      policy_facets: %{"privacy" => "browser_conversation", "residency" => "host"},
59
      module_metadata:
60
        Metadata.first_party("command.execute", "browser_conversation",
61
          effect: :reversible_write,
62
          privacy: "browser_conversation",
63
          residency: "host",
64
          surfaces: ["text", "voice"],
65
          approval_class: "exact_current_user_consent",
66
          approval_enforcement: "host_receipt"
67
        ),
68
      timeout_ms: (@maximum_timeout_seconds + 10) * 1_000,
69
      maximum_input_bytes: 8_192,
70
      maximum_output_bytes: 96 * 1_024,
71
      implementation: __MODULE__
72
    }
73
  end
74
75
  @impl true
76
  def execute(%{"command" => command} = arguments, context) when is_binary(command) do
77
    with :ok <- validate_command(command),
78
         {:ok, timeout_ms} <- timeout_ms(arguments),
79
         {:ok, workspace} <- WorkspaceFiles.resolve_root(context, :write),
80
         {:ok, launcher} <- launcher(workspace.root),
81
         {:ok, run} <-
82
           reserve(workspace.root, fn -> run_command(launcher, workspace, command, timeout_ms) end) do
83
      build_result(workspace, command, launcher, run)
84
    end
85
  end
86
87
  def execute(_arguments, _context), do: {:error, :invalid_command}
88
89
  @doc "Removes expired command-output artifacts from the host artifact store."
90
  def purge_expired_artifacts(now \\ DateTime.utc_now()) do
91
    store =
92
      Application.get_env(
93
        :openagents,
94
        :workspace_snapshot_dir,
95
        Path.join(System.tmp_dir!(), "openagents-workspace-snapshots")
96
      )
97
98
    with true <- is_binary(store), {:ok, entries} <- File.ls(store) do
99
      Enum.each(entries, fn entry ->
100
        directory = Path.join(store, entry)
101
        manifest = Path.join(directory, "manifest.json")
102
103
        with {:ok, encoded} <- File.read(manifest),
104
             {:ok, %{"kind" => "command_output", "expires_at" => expires_at}} <-
105
               Jason.decode(encoded),
106
             {:ok, expiry, _offset} <- DateTime.from_iso8601(expires_at),
107
             :lt <- DateTime.compare(expiry, now) do
108
          File.rm_rf(directory)
109
        else
110
          _keep -> :ok
111
        end
112
      end)
113
    end
114
115
    :ok
116
  end
117
118
  defp validate_command(command) do
119
    cond do
120
      String.trim(command) == "" -> {:error, :invalid_command}
121
      not String.valid?(command) -> {:error, :invalid_command}
122
      String.contains?(command, "\0") -> {:error, :invalid_command}
123
      true -> :ok
124
    end
125
  end
126
127
  defp timeout_ms(arguments) do
128
    case Map.get(arguments, "timeout_seconds", @default_timeout_seconds) do
129
      seconds when is_integer(seconds) and seconds >= 1 and seconds <= @maximum_timeout_seconds ->
130
        {:ok, seconds * 1_000}
131
132
      _invalid ->
133
        {:error, :invalid_command_timeout}
134
    end
135
  end
136
137
  # One command at a time per workspace: a second concurrent call is refused
138
  # instead of queued, so a stuck command cannot pile up hidden work.
139
  defp reserve(root, operation) do
140
    case :global.trans({{__MODULE__, root}, self()}, fn -> {:ok, operation.()} end, [node()], 0) do
141
      :aborted -> {:error, :command_concurrency_limit}
142
      result -> result
143
    end
144
  end
145
146
  # The launch chain is `env -i` (emptied environment), then `unshare -r -n`
147
  # when the host supports unprivileged network namespaces (network denied by
148
  # default), then `setsid -w` (own session and process group so the whole
149
  # tree can be cancelled, forwarding the exit status), then `sh -c`.
150
  defp launcher(root) do
151
    sh = System.find_executable("sh")
152
    env = System.find_executable("env")
153
    setsid = System.find_executable("setsid")
154
155
    if is_nil(sh) or is_nil(env) do
156
      {:error, :command_executor_unavailable}
157
    else
158
      {network, sandbox} = network_sandbox()
159
160
      environment = [
161
        "-i",
162
        "PATH=#{@path}",
163
        "HOME=#{root}",
164
        "LANG=C.UTF-8",
165
        "LC_ALL=C.UTF-8",
166
        "TERM=dumb"
167
      ]
168
169
      session = if setsid, do: [setsid, "-w"], else: []
170
      # The user command runs in an inner shell so that signal-terminated
171
      # commands surface as a conventional 128+signal exit status.
172
      chain = environment ++ sandbox ++ session ++ [sh, "-c", ~S(sh -c "$0")]
173
      {:ok, %{path: env, prefix: chain, network: network}}
174
    end
175
  end
176
177
  defp network_sandbox do
178
    case :persistent_term.get({__MODULE__, :network_sandbox}, :unknown) do
179
      :unknown ->
180
        sandbox = probe_network_sandbox()
181
        :persistent_term.put({__MODULE__, :network_sandbox}, sandbox)
182
        sandbox
183
184
      sandbox ->
185
        sandbox
186
    end
187
  end
188
189
  defp probe_network_sandbox do
190
    with unshare when is_binary(unshare) <- System.find_executable("unshare"),
191
         {_output, 0} <- System.cmd(unshare, ["-r", "-n", "true"], stderr_to_stdout: true) do
192
      {"denied", [unshare, "-r", "-n"]}
193
    else
194
      _unavailable -> {"unrestricted", []}
195
    end
196
  rescue
197
    _error -> {"unrestricted", []}
198
  end
199
200
  defp run_command(launcher, workspace, command, timeout_ms) do
201
    parent = self()
202
203
    port =
204
      Port.open({:spawn_executable, launcher.path}, [
205
        :binary,
206
        :exit_status,
207
        :stderr_to_stdout,
208
        :hide,
209
        {:args, launcher.prefix ++ [command]},
210
        {:cd, workspace.root}
211
      ])
212
213
    os_pid =
214
      case Port.info(port, :os_pid) do
215
        {:os_pid, pid} -> pid
216
        _closed -> nil
217
      end
218
219
    janitor = start_janitor(parent, os_pid)
220
    started = System.monotonic_time(:millisecond)
221
    outcome = collect(port, os_pid, [], 0, started + timeout_ms)
222
    send(janitor, :done)
223
    Map.put(outcome, :duration_ms, System.monotonic_time(:millisecond) - started)
224
  end
225
226
  # If the tool task is killed mid-command (host timeout or cancellation from
227
  # any client), the janitor outlives it and still tears down the process tree.
228
  defp start_janitor(parent, os_pid) do
229
    spawn(fn ->
230
      reference = Process.monitor(parent)
231
232
      receive do
233
        :done -> :ok
234
        {:DOWN, ^reference, :process, ^parent, _reason} -> kill_tree(os_pid)
235
      end
236
    end)
237
  end
238
239
  defp collect(port, os_pid, chunks, bytes, deadline) do
240
    remaining = max(deadline - System.monotonic_time(:millisecond), 0)
241
242
    receive do
243
      {^port, {:data, chunk}} ->
244
        chunks = [chunk | chunks]
245
        bytes = bytes + byte_size(chunk)
246
247
        if bytes > @maximum_captured_bytes do
248
          chunks = shutdown(port, os_pid, chunks)
249
          %{status: :output_limited, exit_status: nil, output: captured(chunks)}
250
        else
251
          collect(port, os_pid, chunks, bytes, deadline)
252
        end
253
254
      {^port, {:exit_status, status}} ->
255
        %{status: :exited, exit_status: status, output: captured(chunks)}
256
    after
257
      remaining ->
258
        chunks = shutdown(port, os_pid, chunks)
259
        %{status: :timed_out, exit_status: nil, output: captured(chunks)}
260
    end
261
  end
262
263
  defp shutdown(port, os_pid, chunks) do
264
    kill_tree(os_pid)
265
    drain(port, chunks)
266
  end
267
268
  defp drain(port, chunks) do
269
    receive do
270
      {^port, {:data, chunk}} -> drain(port, [chunk | chunks])
271
      {^port, {:exit_status, _status}} -> chunks
272
    after
273
      2_000 ->
274
        close_port(port)
275
        chunks
276
    end
277
  end
278
279
  defp close_port(port) do
280
    Port.close(port)
281
    :ok
282
  rescue
283
    ArgumentError -> :ok
284
  end
285
286
  # Kills the spawned process and every descendant. Descendants started by
287
  # `setsid -w` lead their own process groups, so each group whose leader is a
288
  # descendant is killed as a group; the launcher's own group (shared with the
289
  # runtime) is never signalled.
290
  defp kill_tree(nil), do: :ok
291
292
  defp kill_tree(os_pid) do
293
    kill = System.find_executable("kill")
294
295
    if is_binary(kill) do
296
      descendants = descendant_pids(os_pid)
297
298
      Enum.each(descendants, fn pid ->
299
        _group = System.cmd(kill, ["-KILL", "--", "-#{pid}"], stderr_to_stdout: true)
300
      end)
301
302
      Enum.each(descendants ++ [Integer.to_string(os_pid)], fn pid ->
303
        _process = System.cmd(kill, ["-KILL", "--", "#{pid}"], stderr_to_stdout: true)
304
      end)
305
    end
306
307
    :ok
308
  end
309
310
  defp descendant_pids(os_pid) do
311
    case System.cmd("ps", ["-eo", "pid=,ppid="], stderr_to_stdout: true) do
312
      {table, 0} ->
313
        children =
314
          table
315
          |> String.split("\n", trim: true)
316
          |> Enum.reduce(%{}, fn line, acc ->
317
            case String.split(line) do
318
              [pid, ppid] -> Map.update(acc, ppid, [pid], &[pid | &1])
319
              _other -> acc
320
            end
321
          end)
322
323
        collect_descendants(children, [Integer.to_string(os_pid)], [])
324
325
      _failure ->
326
        []
327
    end
328
  end
329
330
  defp collect_descendants(_children, [], found), do: found
331
332
  defp collect_descendants(children, [pid | rest], found) do
333
    next = Map.get(children, pid, [])
334
    collect_descendants(children, next ++ rest, next ++ found)
335
  end
336
337
  defp captured(chunks), do: chunks |> Enum.reverse() |> IO.iodata_to_binary()
338
339
  defp build_result(workspace, command, launcher, run) do
340
    output = run.output |> scrub() |> Redaction.redact_text()
341
    {preview, truncated} = bound_output(output)
342
343
    with {:ok, artifact} <- maybe_store_artifact(workspace, output, truncated) do
344
      {status, exit_code, signal} = classify(run)
345
      receipt = receipt(workspace, command, output)
346
347
      result =
348
        %{
349
          "schema" => "openagents.workspace_bash_result.v1",
350
          "command" => Redaction.redact_text(command),
351
          "status" => status,
352
          "exit_code" => exit_code,
353
          "signal" => signal,
354
          "timed_out" => run.status == :timed_out,
355
          "duration_ms" => run.duration_ms,
356
          "network" => launcher.network,
357
          "output" => preview,
358
          "output_bytes" => byte_size(output),
359
          "returned_bytes" => byte_size(preview),
360
          "truncated" => truncated,
361
          "artifact_ref" => artifact && artifact.ref,
362
          "artifact_expires_at" => artifact && artifact.expires_at,
363
          "workspace_ref" => workspace.ref,
364
          "effect_receipt" => receipt
365
        }
366
367
      refs = [receipt | if(artifact, do: [artifact.ref], else: [])]
368
369
      case command_error(status) do
370
        nil ->
371
          {:ok, %ExecutionResult{result: result, target_receipt_refs: refs}}
372
373
        error ->
374
          {:ok,
375
           %ExecutionResult{
376
             result: result,
377
             status: "failed",
378
             error: error,
379
             target_receipt_refs: refs
380
           }}
381
      end
382
    end
383
  end
384
385
  defp classify(%{status: :timed_out}), do: {"timed_out", nil, nil}
386
  defp classify(%{status: :output_limited}), do: {"output_limited", nil, nil}
387
  defp classify(%{exit_status: 127}), do: {"command_not_found", 127, nil}
388
389
  defp classify(%{exit_status: status}) when is_integer(status) and status > 128,
390
    do: {"signaled", status, status - 128}
391
392
  defp classify(%{exit_status: status}), do: {"exited", status, nil}
393
394
  defp command_error("timed_out"),
395
    do: %{"code" => "command_timed_out", "message" => "The command exceeded its time limit."}
396
397
  defp command_error("output_limited"),
398
    do: %{
399
      "code" => "command_output_limit",
400
      "message" => "The command exceeded the captured output limit."
401
    }
402
403
  defp command_error(_status), do: nil
404
405
  defp scrub(output) do
406
    if String.valid?(output) do
407
      output
408
    else
409
      output
410
      |> String.chunk(:valid)
411
      |> Enum.map_join(fn chunk -> if String.valid?(chunk), do: chunk, else: "\uFFFD" end)
412
    end
413
  end
414
415
  defp bound_output(output) do
416
    lines = String.split(output, "\n")
417
418
    preview =
419
      lines
420
      |> Enum.take(-@maximum_preview_lines)
421
      |> Enum.join("\n")
422
      |> tail_bytes(@maximum_preview_bytes)
423
424
    {preview, preview != output}
425
  end
426
427
  defp tail_bytes(text, limit) when byte_size(text) <= limit, do: text
428
429
  defp tail_bytes(text, limit) do
430
    text
431
    |> binary_part(byte_size(text) - limit, limit)
432
    |> trim_partial_prefix(3)
433
  end
434
435
  defp trim_partial_prefix(text, 0), do: text
436
437
  defp trim_partial_prefix(text, attempts) do
438
    case text do
439
      <<_first, rest::binary>> ->
440
        if String.valid?(text), do: text, else: trim_partial_prefix(rest, attempts - 1)
441
442
      _empty ->
443
        text
444
    end
445
  end
446
447
  defp maybe_store_artifact(_workspace, _output, false), do: {:ok, nil}
448
449
  defp maybe_store_artifact(workspace, output, true) do
450
    id = Ecto.UUID.generate()
451
452
    expires_at =
453
      DateTime.utc_now() |> DateTime.add(@artifact_lifetime_seconds) |> DateTime.to_iso8601()
454
455
    with {:ok, store} <- WorkspaceFiles.snapshot_root(workspace.root),
456
         directory = Path.join(store, id),
457
         :ok <- File.mkdir_p(directory),
458
         :ok <- File.chmod(directory, 0o700),
459
         :ok <- secure_write(Path.join(directory, "output"), output),
460
         :ok <-
461
           secure_write(
462
             Path.join(directory, "manifest.json"),
463
             Jason.encode!(%{
464
               "kind" => "command_output",
465
               "bytes" => byte_size(output),
466
               "expires_at" => expires_at,
467
               "workspace_ref" => workspace.ref
468
             })
469
           ) do
470
      _sweep = purge_expired_artifacts()
471
      {:ok, %{ref: "workspace-artifact:" <> id, expires_at: expires_at}}
472
    else
473
      _failure -> {:error, :workspace_artifact_failed}
474
    end
475
  end
476
477
  defp secure_write(path, content) do
478
    with :ok <- File.write(path, content, [:binary, :exclusive]) do
479
      File.chmod(path, 0o600)
480
    end
481
  end
482
483
  defp receipt(workspace, command, output) do
484
    identity =
485
      WorkspaceFiles.digest(workspace.ref <> "\0" <> command) |> binary_part(0, 24)
486
487
    "workspace-command:" <>
488
      identity <> ":" <> (WorkspaceFiles.digest(output) |> binary_part(0, 32))
489
  end
490
end
lib/openagents/tools/workspace_files.ex modified +10 -1

@@ -16,6 +16,15 @@ defmodule OpenAgents.Tools.WorkspaceFiles do

16 16
17 17
  def resolve(_context, _path, _access), do: {:error, :workspace_required}
18 18
19
  def resolve_root(%ExecutionContext{workspace: workspace}, access)
20
      when is_map(workspace) and access in [:read, :write] do
21
    with {:ok, root} <- workspace_root(workspace, access) do
22
      {:ok, %{root: root, ref: workspace_ref(workspace, root)}}
23
    end
24
  end
25
26
  def resolve_root(_context, _access), do: {:error, :workspace_required}
27
19 28
  def serialize(%{resolved: resolved}, operation),
20 29
    do: :global.trans({{__MODULE__, resolved}, self()}, operation)
21 30

@@ -225,7 +234,7 @@ defmodule OpenAgents.Tools.WorkspaceFiles do

225 234
      "workspace:" <> (digest(root) |> binary_part(0, 16))
226 235
  end
227 236
228
  defp snapshot_root(workspace_root) do
237
  def snapshot_root(workspace_root) do
229 238
    root =
230 239
      Application.get_env(
231 240
        :openagents,
test/openagents/tools/workspace_bash_test.exs added +358

@@ -0,0 +1,358 @@

1
defmodule OpenAgents.Tools.WorkspaceBashTest do
2
  use ExUnit.Case, async: false
3
4
  alias OpenAgents.Tools.{ExecutionContext, Registry, Runner, WorkspaceBash}
5
6
  setup do
7
    base = Path.join(System.tmp_dir!(), "workspace-bash-#{System.unique_integer([:positive])}")
8
    root = Path.join(base, "workspace")
9
    snapshots = Path.join(base, "snapshots")
10
    File.mkdir_p!(root)
11
    previous = Application.get_env(:openagents, :workspace_snapshot_dir)
12
    Application.put_env(:openagents, :workspace_snapshot_dir, snapshots)
13
14
    on_exit(fn ->
15
      if previous,
16
        do: Application.put_env(:openagents, :workspace_snapshot_dir, previous),
17
        else: Application.delete_env(:openagents, :workspace_snapshot_dir)
18
19
      File.rm_rf(base)
20
    end)
21
22
    context = %ExecutionContext{
23
      scope: "browser_conversation",
24
      scope_ref: "conversation:test",
25
      authorities: MapSet.new(["command.execute"]),
26
      workspace: %{
27
        "type" => "repository_workspace",
28
        "root" => root,
29
        "canonical" => false,
30
        "read_only" => false,
31
        "workspace_ref" => "workspace:test"
32
      }
33
    }
34
35
    %{context: context, root: root, snapshots: snapshots}
36
  end
37
38
  test "runs a command in the workspace and returns exit status and receipts", %{
39
    context: context,
40
    root: root
41
  } do
42
    assert {:ok, result} =
43
             WorkspaceBash.execute(%{"command" => "pwd && printf hello"}, context)
44
45
    assert result.status == "succeeded"
46
    assert result.result["status"] == "exited"
47
    assert result.result["exit_code"] == 0
48
    assert result.result["signal"] == nil
49
    assert result.result["timed_out"] == false
50
    assert result.result["truncated"] == false
51
    assert result.result["artifact_ref"] == nil
52
    assert result.result["output"] == "#{root}\nhello"
53
    assert result.result["workspace_ref"] == "workspace:test"
54
    assert is_integer(result.result["duration_ms"])
55
    assert [receipt] = result.target_receipt_refs
56
    assert String.starts_with?(receipt, "workspace-command:")
57
    assert result.result["effect_receipt"] == receipt
58
  end
59
60
  test "interleaves stderr with stdout in order", %{context: context} do
61
    assert {:ok, result} =
62
             WorkspaceBash.execute(
63
               %{"command" => "echo one && echo two 1>&2 && echo three"},
64
               context
65
             )
66
67
    assert result.result["output"] == "one\ntwo\nthree\n"
68
  end
69
70
  test "reports a nonzero exit as an executed outcome", %{context: context} do
71
    assert {:ok, result} = WorkspaceBash.execute(%{"command" => "printf oops; exit 3"}, context)
72
73
    assert result.status == "succeeded"
74
    assert result.result["status"] == "exited"
75
    assert result.result["exit_code"] == 3
76
    assert result.result["output"] == "oops"
77
  end
78
79
  test "distinguishes command-not-found", %{context: context} do
80
    assert {:ok, result} =
81
             WorkspaceBash.execute(%{"command" => "definitely-not-a-command-123"}, context)
82
83
    assert result.result["status"] == "command_not_found"
84
    assert result.result["exit_code"] == 127
85
  end
86
87
  test "distinguishes a signal-terminated command", %{context: context} do
88
    assert {:ok, result} = WorkspaceBash.execute(%{"command" => "kill -TERM $$"}, context)
89
90
    assert result.result["status"] == "signaled"
91
    assert result.result["signal"] == 15
92
  end
93
94
  test "times out, kills the whole process tree, and keeps captured output", %{
95
    context: context,
96
    root: root
97
  } do
98
    command = "echo before; sleep 30 & echo $! > child.pid; sleep 30"
99
100
    assert {:ok, result} =
101
             WorkspaceBash.execute(
102
               %{"command" => command, "timeout_seconds" => 1},
103
               context
104
             )
105
106
    assert result.status == "failed"
107
    assert result.error["code"] == "command_timed_out"
108
    assert result.result["status"] == "timed_out"
109
    assert result.result["timed_out"] == true
110
    assert result.result["exit_code"] == nil
111
    assert result.result["output"] =~ "before"
112
    assert result.result["duration_ms"] >= 1_000
113
114
    child_pid = root |> Path.join("child.pid") |> File.read!() |> String.trim()
115
    assert wait_until(fn -> not os_process_alive?(child_pid) end)
116
  end
117
118
  test "cleans up the process tree when the tool task is killed mid-command", %{
119
    context: context,
120
    root: root
121
  } do
122
    start_supervised!({Task.Supervisor, name: __MODULE__.CancelSupervisor})
123
124
    task =
125
      Task.Supervisor.async_nolink(__MODULE__.CancelSupervisor, fn ->
126
        WorkspaceBash.execute(%{"command" => "echo $$ > shell.pid; sleep 30"}, context)
127
      end)
128
129
    pid_file = Path.join(root, "shell.pid")
130
    assert wait_until(fn -> File.exists?(pid_file) end)
131
    shell_pid = pid_file |> File.read!() |> String.trim()
132
    assert os_process_alive?(shell_pid)
133
134
    _shutdown = Task.shutdown(task, :brutal_kill)
135
    assert wait_until(fn -> not os_process_alive?(shell_pid) end)
136
  end
137
138
  test "bounds the preview to the last lines and stores the full output artifact", %{
139
    context: context,
140
    snapshots: snapshots
141
  } do
142
    assert {:ok, result} = WorkspaceBash.execute(%{"command" => "seq 1 3000"}, context)
143
144
    assert result.result["truncated"] == true
145
    preview_lines = String.split(result.result["output"], "\n", trim: true)
146
    assert length(preview_lines) <= 2_000
147
    assert List.last(preview_lines) == "3000"
148
    refute result.result["output"] =~ ~r/^1\n/
149
150
    artifact_ref = result.result["artifact_ref"]
151
    assert String.starts_with?(artifact_ref, "workspace-artifact:")
152
    assert artifact_ref in result.target_receipt_refs
153
154
    artifact_id = String.replace_prefix(artifact_ref, "workspace-artifact:", "")
155
    full_output = File.read!(Path.join([snapshots, artifact_id, "output"]))
156
    assert String.starts_with?(full_output, "1\n2\n")
157
    assert full_output =~ "\n3000\n"
158
159
    {:ok, expires_at, _offset} = DateTime.from_iso8601(result.result["artifact_expires_at"])
160
    assert DateTime.compare(expires_at, DateTime.utc_now()) == :gt
161
  end
162
163
  test "expired output artifacts are purged and write snapshots are kept", %{
164
    snapshots: snapshots
165
  } do
166
    expired = Path.join(snapshots, "expired-artifact")
167
    File.mkdir_p!(expired)
168
169
    File.write!(
170
      Path.join(expired, "manifest.json"),
171
      Jason.encode!(%{
172
        "kind" => "command_output",
173
        "expires_at" => "2020-01-01T00:00:00Z",
174
        "bytes" => 1
175
      })
176
    )
177
178
    write_snapshot = Path.join(snapshots, "write-snapshot")
179
    File.mkdir_p!(write_snapshot)
180
    File.write!(Path.join(write_snapshot, "manifest.json"), Jason.encode!(%{"existed" => true}))
181
182
    assert :ok = WorkspaceBash.purge_expired_artifacts()
183
    refute File.exists?(expired)
184
    assert File.exists?(write_snapshot)
185
  end
186
187
  test "redacts credential-shaped output and starts from an emptied environment", %{
188
    context: context
189
  } do
190
    secret = "sk-or-v1-abcdefghijklmnopqrstuvwxyz012345"
191
    System.put_env("WORKSPACE_BASH_TEST_SECRET", secret)
192
    on_exit(fn -> System.delete_env("WORKSPACE_BASH_TEST_SECRET") end)
193
194
    assert {:ok, environment} = WorkspaceBash.execute(%{"command" => "env"}, context)
195
    refute environment.result["output"] =~ "WORKSPACE_BASH_TEST_SECRET"
196
    refute environment.result["output"] =~ secret
197
198
    assert {:ok, echoed} =
199
             WorkspaceBash.execute(%{"command" => "echo token=#{secret}"}, context)
200
201
    refute echoed.result["output"] =~ secret
202
    assert echoed.result["output"] =~ "[REDACTED]"
203
    refute echoed.result["command"] =~ secret
204
  end
205
206
  test "denies network access when the host supports network namespaces", %{context: context} do
207
    assert {:ok, probe} = WorkspaceBash.execute(%{"command" => "true"}, context)
208
209
    case probe.result["network"] do
210
      "denied" ->
211
        assert {:ok, interfaces} =
212
                 WorkspaceBash.execute(%{"command" => "cat /proc/net/dev"}, context)
213
214
        names =
215
          interfaces.result["output"]
216
          |> String.split("\n", trim: true)
217
          |> Enum.flat_map(fn line ->
218
            case String.split(line, ":", parts: 2) do
219
              [name, _stats] -> [String.trim(name)]
220
              _header -> []
221
            end
222
          end)
223
224
        assert names in [["lo"], []]
225
226
      "unrestricted" ->
227
        :ok
228
    end
229
  end
230
231
  test "refuses canonical, connected, read-only, and missing workspaces", %{
232
    context: context,
233
    root: root
234
  } do
235
    canonical = put_in(context.workspace["canonical"], true)
236
237
    assert {:error, :canonical_workspace_refused} =
238
             WorkspaceBash.execute(%{"command" => "true"}, canonical)
239
240
    connected = %{context | workspace: %{"type" => "connected_forge_repository", "root" => root}}
241
242
    assert {:error, :workspace_required} =
243
             WorkspaceBash.execute(%{"command" => "true"}, connected)
244
245
    read_only = put_in(context.workspace["read_only"], true)
246
247
    assert {:error, :workspace_read_only} =
248
             WorkspaceBash.execute(%{"command" => "true"}, read_only)
249
250
    assert {:error, :workspace_required} =
251
             WorkspaceBash.execute(%{"command" => "true"}, %{context | workspace: nil})
252
  end
253
254
  test "refuses the canonical application checkout as a command workspace", %{context: context} do
255
    hosted = put_in(context.workspace["root"], OpenAgents.Tools.Repository.source_dir())
256
257
    assert {:error, :canonical_workspace_refused} =
258
             WorkspaceBash.execute(%{"command" => "true"}, hosted)
259
  end
260
261
  test "rejects invalid commands and timeouts", %{context: context} do
262
    assert {:error, :invalid_command} = WorkspaceBash.execute(%{"command" => "   "}, context)
263
    assert {:error, :invalid_command} = WorkspaceBash.execute(%{"command" => "a\0b"}, context)
264
265
    assert {:error, :invalid_command_timeout} =
266
             WorkspaceBash.execute(%{"command" => "true", "timeout_seconds" => 0}, context)
267
268
    assert {:error, :invalid_command_timeout} =
269
             WorkspaceBash.execute(%{"command" => "true", "timeout_seconds" => 500}, context)
270
  end
271
272
  test "refuses a second concurrent command in the same workspace", %{
273
    context: context,
274
    root: root
275
  } do
276
    ready = Path.join(root, "ready.fifo")
277
    release = Path.join(root, "release.fifo")
278
    {_output, 0} = System.cmd("mkfifo", [ready, release])
279
280
    start_supervised!({Task.Supervisor, name: __MODULE__.ConcurrencySupervisor})
281
282
    first =
283
      Task.Supervisor.async(__MODULE__.ConcurrencySupervisor, fn ->
284
        WorkspaceBash.execute(%{"command" => "echo go > ready.fifo && cat release.fifo"}, context)
285
      end)
286
287
    {"go\n", 0} = System.cmd("cat", [ready])
288
289
    assert {:error, :command_concurrency_limit} =
290
             WorkspaceBash.execute(%{"command" => "true"}, context)
291
292
    {_output, 0} = System.cmd("sh", ["-c", "echo done > #{release}"])
293
    assert {:ok, result} = Task.await(first)
294
    assert result.result["exit_code"] == 0
295
  end
296
297
  test "runner requires command.execute authority and an exact approval receipt", %{
298
    context: context
299
  } do
300
    assert {:ok, snapshot} = Registry.build([WorkspaceBash])
301
302
    call = %{
303
      call_id: "call-workspace-bash",
304
      name: "bash",
305
      version: 1,
306
      raw_arguments: Jason.encode!(%{"command" => "printf approved"})
307
    }
308
309
    assert {:ok, refused_approval} = Runner.run(snapshot, call, context)
310
    assert refused_approval["error"]["code"] == "module_approval_required"
311
312
    receipt = %{
313
      "schema" => "sarah.module_approval.v1",
314
      "approval_class" => "exact_current_user_consent",
315
      "module_id" => WorkspaceBash.specification().module_id,
316
      "version" => 1,
317
      "scope_ref" => context.scope_ref,
318
      "explicit" => true,
319
      "actor_type" => "person",
320
      "receipt_ref" => "approval:workspace-bash"
321
    }
322
323
    assert {:ok, refused_authority} =
324
             Runner.run(snapshot, call, %{
325
               context
326
               | authorities: MapSet.new(),
327
                 approval_receipts: [receipt]
328
             })
329
330
    assert refused_authority["error"]["code"] == "authority_refused"
331
332
    assert {:ok, approved} =
333
             Runner.run(snapshot, call, %{context | approval_receipts: [receipt]})
334
335
    assert approved["status"] == "succeeded"
336
    assert approved["result"]["output"] == "approved"
337
    assert approved["result"]["exit_code"] == 0
338
  end
339
340
  defp wait_until(check, attempts \\ 200)
341
  defp wait_until(_check, 0), do: false
342
343
  defp wait_until(check, attempts) do
344
    if check.() do
345
      true
346
    else
347
      Process.sleep(25)
348
      wait_until(check, attempts - 1)
349
    end
350
  end
351
352
  defp os_process_alive?(os_pid) do
353
    case System.cmd("kill", ["-0", os_pid], stderr_to_stdout: true) do
354
      {_output, 0} -> true
355
      {_output, _nonzero} -> false
356
    end
357
  end
358
end

This page updates live while a promote is in flight · changelog