nexo_ai 0.1.0 → 0.7.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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/.document +3 -0
  3. data/CHANGELOG.md +232 -0
  4. data/README.md +75 -4
  5. data/Rakefile +29 -0
  6. data/app/views/nexo/_event.html.erb +1 -0
  7. data/docs/concurrency.md +94 -0
  8. data/docs/durable-workflows.md +184 -0
  9. data/docs/getting-started.md +103 -0
  10. data/docs/loops.md +93 -0
  11. data/docs/mcp.md +144 -0
  12. data/docs/permissions.md +47 -0
  13. data/docs/rails.md +129 -0
  14. data/docs/sandboxes.md +290 -0
  15. data/docs/sessions.md +99 -0
  16. data/docs/skills.md +68 -0
  17. data/docs/tools.md +38 -0
  18. data/docs/web.md +130 -0
  19. data/docs/workflows.md +255 -0
  20. data/examples/README.md +51 -0
  21. data/examples/approval_agent.rb +64 -0
  22. data/examples/approval_workflow.rb +62 -0
  23. data/examples/artifact_from_template.rb +54 -0
  24. data/examples/chat_session.rb +49 -0
  25. data/examples/code_reviewer.rb +64 -0
  26. data/examples/container_review.rb +52 -0
  27. data/examples/inbox_digest.rb +62 -0
  28. data/examples/inbox_digest_http.rb +69 -0
  29. data/examples/inbox_digest_task.rb +40 -0
  30. data/examples/mcp_filesystem.rb +50 -0
  31. data/examples/news_search.rb +49 -0
  32. data/examples/news_summary.rb +37 -0
  33. data/examples/rails_usage.md +141 -0
  34. data/examples/skills/email_triage/SKILL.md +47 -0
  35. data/examples/skills/news_summary/SKILL.md +37 -0
  36. data/examples/skills/ruby-code-review/SKILL.md +61 -0
  37. data/lib/generators/nexo/artifacts/artifacts_generator.rb +37 -0
  38. data/lib/generators/nexo/artifacts/templates/add_artifacts_to_nexo_workflow_runs.rb +11 -0
  39. data/lib/generators/nexo/install/install_generator.rb +35 -0
  40. data/lib/generators/nexo/install/templates/nexo.rb +19 -0
  41. data/lib/generators/nexo/skill/skill_generator.rb +45 -0
  42. data/lib/generators/nexo/skill/templates/SKILL.md.tt +10 -0
  43. data/lib/generators/nexo/state/state_generator.rb +37 -0
  44. data/lib/generators/nexo/state/templates/add_state_to_nexo_workflow_runs.rb +13 -0
  45. data/lib/generators/nexo/workflows/templates/create_nexo_workflow_runs.rb +26 -0
  46. data/lib/generators/nexo/workflows/workflows_generator.rb +34 -0
  47. data/lib/nexo/agent.rb +423 -0
  48. data/lib/nexo/concurrent.rb +78 -0
  49. data/lib/nexo/configuration.rb +82 -0
  50. data/lib/nexo/engine.rb +51 -0
  51. data/lib/nexo/loop.rb +30 -0
  52. data/lib/nexo/loops/agent_sdk.rb +68 -0
  53. data/lib/nexo/loops/ruby_llm.rb +66 -0
  54. data/lib/nexo/mcp/gated_tool.rb +70 -0
  55. data/lib/nexo/mcp.rb +96 -0
  56. data/lib/nexo/output_truncator.rb +41 -0
  57. data/lib/nexo/permissions.rb +162 -0
  58. data/lib/nexo/read_tracker.rb +32 -0
  59. data/lib/nexo/run_store.rb +162 -0
  60. data/lib/nexo/sandbox.rb +64 -0
  61. data/lib/nexo/sandboxes/container.rb +293 -0
  62. data/lib/nexo/sandboxes/local.rb +157 -0
  63. data/lib/nexo/sandboxes/remote.rb +77 -0
  64. data/lib/nexo/sandboxes/virtual.rb +42 -0
  65. data/lib/nexo/sandboxes.rb +43 -0
  66. data/lib/nexo/session.rb +152 -0
  67. data/lib/nexo/skills.rb +54 -0
  68. data/lib/nexo/tools/fetch.rb +133 -0
  69. data/lib/nexo/tools/glob.rb +28 -0
  70. data/lib/nexo/tools/read_file.rb +54 -0
  71. data/lib/nexo/tools/shell.rb +35 -0
  72. data/lib/nexo/tools/web_search.rb +81 -0
  73. data/lib/nexo/tools/write_file.rb +61 -0
  74. data/lib/nexo/turbo_broadcaster.rb +41 -0
  75. data/lib/nexo/version.rb +2 -1
  76. data/lib/nexo/workflow.rb +705 -0
  77. data/lib/nexo/workflow_job.rb +42 -0
  78. data/lib/nexo/workflow_run.rb +128 -0
  79. data/lib/nexo.rb +138 -1
  80. data/lib/tasks/nexo.rake +17 -0
  81. data/sig/nexo/agent.rbs +23 -0
  82. data/sig/nexo/output_truncator.rbs +7 -0
  83. data/sig/nexo/permissions.rbs +15 -0
  84. data/sig/nexo/read_tracker.rbs +8 -0
  85. data/sig/nexo/sandbox.rbs +12 -0
  86. data/sig/nexo/sandboxes/container.rbs +26 -0
  87. data/sig/nexo/sandboxes/local.rbs +12 -0
  88. data/sig/nexo/sandboxes/virtual.rbs +7 -0
  89. data/sig/nexo/sandboxes.rbs +5 -0
  90. data/sig/nexo/tools.rbs +28 -0
  91. data/sig/nexo_ai.rbs +22 -1
  92. metadata +185 -2
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nexo
4
+ # The authorization gate for a sandbox's capabilities. Each tool asks
5
+ # +authorize!+ before it touches the sandbox, so gating is provider-independent
6
+ # and does not rely on any framework callback.
7
+ #
8
+ # Modes:
9
+ # * +:auto+ — allow everything.
10
+ # * +:read_only+ — allow +:read+/+:glob+, deny +:write+/+:shell+/+:fetch+/+:search+ (the default).
11
+ # * +:ask+ — defer to +on_ask+; a truthy return allows, anything else denies.
12
+ # * +:approve+ — durable, cross-process sibling of +:ask+ (Spec 16): with no
13
+ # +decision+ it raises Nexo::ApprovalRequired (→ Workflow#run_agent
14
+ # suspends the run); with +{approved: true}+ it allows, with
15
+ # +{approved: false}+ it Denied denies.
16
+ #
17
+ # Capabilities are +:read+, +:glob+, +:write+, +:shell+, +:fetch+, +:search+.
18
+ # Anything listed in +allow:+ is permitted regardless of mode.
19
+ class Permissions
20
+ # The recognized permission modes: +:auto+, +:read_only+, +:ask+, +:approve+.
21
+ MODES = %i[auto read_only ask approve].freeze
22
+
23
+ # Raised when a capability is not authorized. Tools rescue this and return
24
+ # +{ error: ... }+ so the agent loop continues.
25
+ class Denied < StandardError; end
26
+
27
+ # The configured Nexo permission mode (one of MODES). Read by the agent to
28
+ # map onto an opt-in backend's own permission vocabulary (see
29
+ # +Agent#permission_mode+).
30
+ attr_reader :mode
31
+
32
+ # The approval decision under +:approve+ (Spec 16): +nil+ (undecided ⇒
33
+ # suspend) or a +{approved: true|false}+ Hash. Writable after construction so
34
+ # Workflow#run_agent can thread a resume decision into an
35
+ # already-resolved +:approve+ gate without rebuilding it. Ignored by every
36
+ # other mode.
37
+ attr_accessor :decision
38
+
39
+ # +ask_when:+ is an optional +->(capability, detail)+ predicate that scopes
40
+ # *which* actions actually prompt under +:ask+ (and, unchanged, under
41
+ # +:approve+): when it returns falsey the action is auto-allowed without
42
+ # calling +on_ask+ / requiring a decision; truthy (or when unset) falls
43
+ # through to +on_ask+ / the approval gate exactly as before. It only ever
44
+ # *narrows* what is auto-allowed from the "ask/approve for everything"
45
+ # baseline — it never widens authority. Applies to #authorize! only, not
46
+ # #authorize_mcp!. +approve_when:+ is an alias that maps onto the same
47
+ # predicate (there is one predicate, not two — Spec 16 Q4).
48
+ #
49
+ # +decision:+ (default +nil+) seeds the +:approve+ decision (see #decision).
50
+ def initialize(mode: :read_only, allow: %i[read glob], mcp_allow: [], on_ask: nil, ask_when: nil,
51
+ approve_when: nil, decision: nil)
52
+ raise ArgumentError, "unknown mode #{mode}" unless MODES.include?(mode)
53
+
54
+ @mode = mode
55
+ @allow = allow
56
+ @mcp_allow = mcp_allow.map(&:to_s)
57
+ @on_ask = on_ask
58
+ @ask_when = ask_when || approve_when
59
+ @decision = decision
60
+ end
61
+
62
+ # Returns a copy of this gate carrying +decision+ (a +{approved: …}+ Hash or
63
+ # +nil+), leaving the receiver untouched (Spec 16). Used to thread a per-run
64
+ # resume decision into a user-supplied, class-level +:approve+ +Permissions+
65
+ # without mutating the shared instance.
66
+ def with_decision(decision)
67
+ dup.tap { |copy| copy.decision = decision }
68
+ end
69
+
70
+ # Authorizes +capability+ (with optional +detail+ passed to an +:ask+ hook).
71
+ # Returns +true+ when allowed; raises Denied otherwise.
72
+ def authorize!(capability, detail = nil)
73
+ return true if @allow.include?(capability)
74
+
75
+ case @mode
76
+ when :auto
77
+ true
78
+ when :read_only
79
+ if %i[write shell fetch search].include?(capability)
80
+ raise Denied, "#{capability} denied in read_only mode"
81
+ end
82
+ true
83
+ when :ask
84
+ # Scoped-ask: when ask_when says this action doesn't need a prompt,
85
+ # auto-allow without calling on_ask. Unset ask_when = ask for everything.
86
+ return true if @ask_when && !@ask_when.call(capability, detail)
87
+
88
+ unless @on_ask&.call(capability, detail)
89
+ raise Denied, "#{capability} (#{detail}) denied by user"
90
+ end
91
+ true
92
+ when :approve
93
+ # Durable approval (Spec 16). Scoped-approve: when ask_when says this
94
+ # action doesn't need approval, auto-allow without a decision. Unset
95
+ # ask_when = approve for everything.
96
+ return true if @ask_when && !@ask_when.call(capability, detail)
97
+
98
+ if @decision.nil?
99
+ # Undecided ⇒ pause the run for a human (Branch A): the signal
100
+ # propagates out of the tool loop; run_agent turns it into a suspend.
101
+ raise Nexo::ApprovalRequired.new(capability, detail)
102
+ elsif @decision[:approved]
103
+ true
104
+ else
105
+ # Explicitly denied on resume ⇒ never auto-allow; the tool rescues
106
+ # Denied into {error:} and the model adapts.
107
+ raise Denied, "#{capability} (#{detail}) not approved"
108
+ end
109
+ end
110
+ end
111
+
112
+ # Authorizes an MCP tool *call* by name. A deliberate sibling of #authorize!
113
+ # on a separate capability axis: an MCP tool runs inside the MCP server,
114
+ # outside the sandbox, so this gates the authority to *invoke* it — a different
115
+ # guarantee than sandbox capability. Fails closed under +:read_only+ (nothing
116
+ # allowed unless the exact +tool_name+ is listed in +mcp_allow+).
117
+ #
118
+ # * +:auto+ — allow every MCP tool.
119
+ # * +:read_only+ — allow only names in +mcp_allow+ (default +[]+ ⇒ deny all).
120
+ # * +:ask+ — defer to +on_ask+ with +(:mcp, {tool:, args:})+; a truthy
121
+ # return allows, anything else denies.
122
+ # * +:approve+ — durable sibling of +:ask+ on the MCP axis: names in
123
+ # +mcp_allow+ are pre-approved, anything else needs a decision — undecided
124
+ # raises Nexo::ApprovalRequired (→ Workflow#run_agent suspends), +approved+
125
+ # allows, +approved: false+ Denies.
126
+ #
127
+ # Returns +true+ when allowed; raises Denied otherwise. The +else+ is a
128
+ # fail-closed backstop: a future mode that forgets to extend this gate denies
129
+ # by default rather than silently allowing (the bug this replaced).
130
+ def authorize_mcp!(tool_name, args = {})
131
+ name = tool_name.to_s
132
+
133
+ case @mode
134
+ when :auto
135
+ true
136
+ when :read_only
137
+ return true if @mcp_allow.include?(name)
138
+
139
+ raise Denied, "mcp tool #{name} denied in read_only mode (not in mcp_allow)"
140
+ when :ask
141
+ unless @on_ask&.call(:mcp, {tool: name, args: args})
142
+ raise Denied, "mcp tool #{name} denied by user"
143
+ end
144
+ true
145
+ when :approve
146
+ # Pre-approved read tools pass; everything else routes through the same
147
+ # decision gate as #authorize!'s :approve branch.
148
+ return true if @mcp_allow.include?(name)
149
+
150
+ if @decision.nil?
151
+ raise Nexo::ApprovalRequired.new(:mcp, name, args)
152
+ elsif @decision[:approved]
153
+ true
154
+ else
155
+ raise Denied, "mcp tool #{name} not approved"
156
+ end
157
+ else
158
+ raise Denied, "mcp tool #{name} denied (unhandled mode #{@mode})"
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nexo
4
+ # A tiny per-chat memory of which files an agent has read and at what
5
+ # last-modified time, so Tools::WriteFile can refuse to overwrite a file the
6
+ # model never read — or one that changed underneath it — within a session.
7
+ #
8
+ # Built once per chat in Agent#chat and threaded into both Tools::ReadFile
9
+ # (which records) and Tools::WriteFile (which enforces). Scope is clobber
10
+ # safety *within a session* only: no versioning, no locking, no VCS semantics.
11
+ class ReadTracker
12
+ # Starts an empty tracker (an internal +path => mtime+ map).
13
+ def initialize
14
+ @mtimes = {}
15
+ end
16
+
17
+ # Records that +path+ was read when it had modification time +mtime+.
18
+ def record(path, mtime)
19
+ @mtimes[path] = mtime
20
+ end
21
+
22
+ # Whether +path+ has been read in this session.
23
+ def read?(path)
24
+ @mtimes.key?(path)
25
+ end
26
+
27
+ # The mtime recorded when +path+ was read, or +nil+ if it was never read.
28
+ def recorded_mtime(path)
29
+ @mtimes[path]
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nexo
4
+ # Storage seam for Workflow runs. Two interchangeable backends expose an
5
+ # identical create/find interface and return run objects responding to the
6
+ # same shape, which is what lets one Workflow implementation drive either
7
+ # the in-memory store (plain Ruby) or the ActiveRecord store (Rails).
8
+ #
9
+ # A run object responds to: +id+, +workflow_class+, +status+, +payload+,
10
+ # +result+, +error+, +events+, +artifacts+, +state+, plus +update!(attrs)+,
11
+ # +push_event(event)+, +save_events!+, +push_artifact(artifact)+,
12
+ # +save_artifacts!+, and +save_state!+ (Spec 13).
13
+ module RunStore
14
+ # Selects a backend: the ActiveRecord store when both ::ActiveRecord::Base
15
+ # and Nexo::WorkflowRun are defined (the Rails path), otherwise the in-memory
16
+ # store. With no Rails loaded the AR check short-circuits, so the plain-Ruby
17
+ # path never references ActiveRecord.
18
+ def self.default
19
+ if defined?(::ActiveRecord::Base) && defined?(Nexo::WorkflowRun)
20
+ ActiveRecord.new
21
+ else
22
+ Memory.new
23
+ end
24
+ end
25
+
26
+ # In-memory backend used by the plain-Ruby path and the offline test suite.
27
+ # Runs are held in a process-wide Hash keyed by their UUID id so that a run
28
+ # created by Workflow.run is still findable through a *later*
29
+ # RunStore.default call (e.g. Workflow.logs) — each call builds a fresh
30
+ # Memory instance, but they all share the same underlying store, mirroring
31
+ # how the ActiveRecord backend shares one database. Nothing is persisted to
32
+ # disk; the store lives only for the process.
33
+ class Memory
34
+ # A run record with the shared store shape. +update!+ assigns attributes,
35
+ # +push_event+ appends to the event log, and +save_events!+ is a no-op
36
+ # (the AR backend persists; Memory keeps everything in the Struct).
37
+ # Built with keyword arguments; a Struct defined without +keyword_init:+
38
+ # accepts them on Ruby 3.2+, so (well within the 3.3 floor) +keyword_init:
39
+ # true+ is unnecessary.
40
+ # Mutations serialize on the store-wide mutex (see Memory.mutex): the
41
+ # process-wide store can be driven from multiple threads (Local#offload
42
+ # workers, run_later under a threaded adapter, a multithreaded Puma host
43
+ # without ActiveRecord), and on Rubies without a GVL a bare +<<+/+[]=+ can
44
+ # lose events or corrupt the table.
45
+ Run = Struct.new(:id, :workflow_class, :status, :payload, :result, :error, :events, :artifacts, :state) do
46
+ def update!(attrs) = Memory.mutex.synchronize { attrs.each { |k, v| self[k] = v } }
47
+
48
+ def push_event(ev) = Memory.mutex.synchronize { events << ev }
49
+
50
+ def save_events! = nil
51
+
52
+ def push_artifact(a) = Memory.mutex.synchronize { artifacts << a }
53
+
54
+ def save_artifacts! = nil
55
+
56
+ # No-op counterpart to the AR model's save_state! — the Struct member
57
+ # already holds the checkpoint/suspend state in memory (Spec 13).
58
+ def save_state! = nil
59
+
60
+ # Read helpers mirroring Nexo::WorkflowRun so host code written against a
61
+ # finished run behaves identically in plain Ruby and under Rails (the
62
+ # "same run shape" contract covered only the write side before).
63
+ def done? = status == "done"
64
+
65
+ def failed? = status == "failed"
66
+
67
+ def running? = status == "running"
68
+
69
+ def queued? = status == "queued"
70
+
71
+ def suspended? = status == "suspended"
72
+
73
+ # The reason recorded when the run was suspended, or nil if it never was.
74
+ def suspend_reason = state&.dig("__suspend__", "reason")
75
+
76
+ # The stored result of a completed checkpoint(name), or nil.
77
+ def checkpoint_result(name) = state&.[](name.to_s)
78
+
79
+ # A recorded artifact by name (string/symbol tolerant), or nil.
80
+ def artifact(name) = (artifacts || []).find { |a| (a["name"] || a[:name]) == name.to_s }
81
+
82
+ # Just the stored body of a named artifact, or nil.
83
+ def artifact_content(name) = artifact(name)&.then { |a| a["content"] || a[:content] }
84
+ end
85
+
86
+ @runs = {}
87
+ @mutex = Mutex.new
88
+
89
+ class << self
90
+ # The shared run table. Ids are UUIDs, so runs from independent callers
91
+ # never collide.
92
+ attr_reader :runs
93
+
94
+ # The store-wide lock guarding +runs+ and every run mutation. Not
95
+ # reentrant, so no method that holds it calls another that grabs it.
96
+ attr_reader :mutex
97
+
98
+ # Clears the shared table. Intended for test isolation.
99
+ def reset!
100
+ @mutex.synchronize { @runs = {} }
101
+ end
102
+ end
103
+
104
+ # Builds a fresh +"pending"+ Run (UUID id, empty events/artifacts/state) and
105
+ # stores it in the process-wide table, returning it.
106
+ def create(workflow_class:, payload:)
107
+ run = Run.new(
108
+ id: Nexo.generate_run_id,
109
+ workflow_class: workflow_class,
110
+ status: "pending",
111
+ payload: payload,
112
+ result: nil,
113
+ error: nil,
114
+ events: [],
115
+ artifacts: [],
116
+ state: {}
117
+ )
118
+ self.class.mutex.synchronize { self.class.runs[run.id] = run }
119
+ end
120
+
121
+ # Fetches a run by its UUID string id. A miss raises KeyError, which is
122
+ # acceptable for v1.
123
+ def find(id) = self.class.mutex.synchronize { self.class.runs.fetch(id) }
124
+
125
+ # Atomically claims a +"suspended"+ run for resume: flips it to +"running"+
126
+ # and returns true only if it was still suspended, so two concurrent resumes
127
+ # can't both re-enter #call (Spec 13 double-execution guard). The status
128
+ # flip is direct (not via #update!) to avoid re-entering the non-reentrant
129
+ # mutex.
130
+ def claim_for_resume!(run)
131
+ self.class.mutex.synchronize do
132
+ return false unless run.status == "suspended"
133
+
134
+ run.status = "running"
135
+ true
136
+ end
137
+ end
138
+ end
139
+
140
+ # ActiveRecord backend used by the Rails path. Delegates to the
141
+ # Nexo::WorkflowRun model, which carries the same run shape.
142
+ class ActiveRecord
143
+ # Creates and persists a +"pending"+ Nexo::WorkflowRun row, returning it.
144
+ def create(workflow_class:, payload:)
145
+ Nexo::WorkflowRun.create!(workflow_class: workflow_class, payload: payload, status: "pending")
146
+ end
147
+
148
+ # Fetches a persisted run by id (raises +ActiveRecord::RecordNotFound+ on a miss).
149
+ def find(id) = Nexo::WorkflowRun.find(id)
150
+
151
+ # Atomically claims a +"suspended"+ run for resume with a single conditional
152
+ # UPDATE, returning true only for the worker that won the row. This closes
153
+ # the cross-process double-resume race the plain +find+-then-check couldn't:
154
+ # a queued resume_later and a concurrent sync resume can't both re-enter.
155
+ def claim_for_resume!(run)
156
+ Nexo::WorkflowRun
157
+ .where(id: run.id, status: "suspended")
158
+ .update_all(status: "running") == 1
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nexo
4
+ # The execution-environment seam. A sandbox is where an agent's tools actually
5
+ # touch files and run commands; swapping the sandbox swaps the whole execution
6
+ # context (in-memory, host, or — later — remote) by constructor injection.
7
+ #
8
+ # Concrete sandboxes implement the four-method contract below. The base class
9
+ # raises +NotImplementedError+ for each so an incomplete subclass fails loudly.
10
+ #
11
+ # See Sandboxes::Virtual (default, zero host access) and Sandboxes::Local
12
+ # (host filesystem + shell, guarded).
13
+ class Sandbox
14
+ # Returns the contents of +path+ as a String.
15
+ def read(path)
16
+ raise NotImplementedError
17
+ end
18
+
19
+ # Writes +content+ to +path+.
20
+ def write(path, content)
21
+ raise NotImplementedError
22
+ end
23
+
24
+ # Runs +command+ and returns +{ stdout:, stderr:, status: }+ (status is the
25
+ # integer exit code). +timeout+ is in seconds.
26
+ def shell(command, timeout: 30)
27
+ raise NotImplementedError
28
+ end
29
+
30
+ # Returns the paths matching the glob +pattern+.
31
+ def glob(pattern)
32
+ raise NotImplementedError
33
+ end
34
+
35
+ # Releases any resources the sandbox holds. No-op by default.
36
+ def close
37
+ nil
38
+ end
39
+
40
+ # A short, plain-text description of the execution environment (cwd, host
41
+ # access, network) for the agent to inject into the system prompt. Base
42
+ # returns +nil+ — inject nothing. Real-filesystem sandboxes (Local,
43
+ # Container) override this so a weak tool-caller knows where it runs.
44
+ def instructions
45
+ nil
46
+ end
47
+
48
+ # Whether the sandbox supports +capability+ (one of +:read+, +:write+,
49
+ # +:glob+, +:shell+). The base supports everything but +:shell+ (an
50
+ # in-memory sandbox has no process to run a command in), so an agent only
51
+ # attaches the +Shell+ tool when the sandbox reports it. Real-process
52
+ # sandboxes (Local, Container) override to add +:shell+.
53
+ def supports?(capability)
54
+ capability != :shell
55
+ end
56
+
57
+ # The last-modified time of +path+, used by the read-before-write + stale
58
+ # guard for real-filesystem sandboxes. Base returns +nil+ (no external
59
+ # mutation to guard against, e.g. Virtual), which disables the guard.
60
+ def mtime(path)
61
+ nil
62
+ end
63
+ end
64
+ end