connector: block shell write actions inread-only mode

When code execution remote was enabled but CLI was in read-only mode, the shell could still write files to disk.

Alessandro committed Apr 20, 2026 at 03:07 UTC d28c21e1a070a6662f996b0845f26e533718336b
5 files changed +132 -19
plugins/_a0_connector/extensions/python/message_loop_prompts_after/_78_include_code_execution_remote.py
+81 -2
@@ -4,7 +4,10 @@ from agent import LoopData
4 from helpers.extension import Extension
5
6 from plugins._a0_connector.helpers.exec_config import build_exec_config
7 -from plugins._a0_connector.helpers.ws_runtime import select_remote_exec_target_sid
7 +from plugins._a0_connector.helpers.ws_runtime import (
8 + remote_file_metadata_for_sid,
9 + select_remote_exec_target_sid,
10 +)
11
12
13 def _format_timeouts(payload: dict[str, int]) -> str:
@@ -25,9 +28,82 @@ class IncludeCodeExecutionRemote(Extension):
28 return
29
30 context_id = getattr(self.agent.context, "id", "")
28 - if not context_id or not select_remote_exec_target_sid(context_id):
31 + if not context_id:
32 return
33
34 + sid = select_remote_exec_target_sid(context_id, require_writes=False)
35 + if not sid:
36 + return
37 +
38 + metadata = remote_file_metadata_for_sid(sid)
39 + if metadata is None:
40 + access_mode = "Read&Write (legacy/unknown)"
41 + write_runtime_guidance = (
42 + "- `runtime=terminal`, `python`, `nodejs`, and `input` are expected to be "
43 + "available, but this CLI did not advertise an explicit F3 access mode.\n"
44 + "- Use shell syntax that matches the remote host (for example, PowerShell on "
45 + "Windows)."
46 + )
47 + write_runtime_examples = """```json
48 +{
49 + "tool_name": "code_execution_remote",
50 + "tool_args": {
51 + "runtime": "terminal",
52 + "session": 0,
53 + "code": "pwd"
54 + }
55 +}
56 +```
57 +
58 +```json
59 +{
60 + "tool_name": "code_execution_remote",
61 + "tool_args": {
62 + "runtime": "python",
63 + "session": 0,
64 + "code": "import os\\nprint(os.getcwd())"
65 + }
66 +}
67 +```"""
68 + elif metadata.get("write_enabled"):
69 + access_mode = "Read&Write"
70 + write_runtime_guidance = (
71 + "- `runtime=terminal`, `python`, `nodejs`, and `input` may modify files on "
72 + "the remote CLI machine. Use them only when shell-backed execution is the "
73 + "right tool for the job.\n"
74 + "- Use shell syntax that matches the remote host (for example, PowerShell on "
75 + "Windows)."
76 + )
77 + write_runtime_examples = """```json
78 +{
79 + "tool_name": "code_execution_remote",
80 + "tool_args": {
81 + "runtime": "terminal",
82 + "session": 0,
83 + "code": "pwd"
84 + }
85 +}
86 +```
87 +
88 +```json
89 +{
90 + "tool_name": "code_execution_remote",
91 + "tool_args": {
92 + "runtime": "python",
93 + "session": 0,
94 + "code": "import os\\nprint(os.getcwd())"
95 + }
96 +}
97 +```"""
98 + else:
99 + access_mode = "Read only"
100 + write_runtime_guidance = (
101 + "- `runtime=terminal`, `python`, `nodejs`, and `input` are disabled while "
102 + "local access is Read only. Press F3 to switch the host machine to Read&Write "
103 + "before starting new shell-backed work that could modify files."
104 + )
105 + write_runtime_examples = ""
106 +
107 exec_config = build_exec_config(agent=self.agent)
108 code_exec_timeouts = exec_config.get("code_exec_timeouts")
109 output_timeouts = exec_config.get("output_timeouts")
@@ -36,6 +112,9 @@ class IncludeCodeExecutionRemote(Extension):
112
113 prompt = self.agent.read_prompt(
114 "agent.extras.code_execution_remote.md",
115 + access_mode=access_mode,
116 + write_runtime_guidance=write_runtime_guidance,
117 + write_runtime_examples=write_runtime_examples,
118 code_exec_timeouts=_format_timeouts(
119 code_exec_timeouts if isinstance(code_exec_timeouts, dict) else {}
120 ),
plugins/_a0_connector/helpers/ws_runtime.py
+7 -1
@@ -263,7 +263,7 @@ def remote_exec_metadata_for_sid(sid: str) -> dict[str, Any] | None:
263 }
264
265
266 -def select_remote_exec_target_sid(context_id: str) -> str | None:
266 +def select_remote_exec_target_sid(context_id: str, *, require_writes: bool = False) -> str | None:
267 with _state_lock:
268 subscribers = sorted(_context_subscriptions.get(context_id, set()))
269 fallback_sid: str | None = None
@@ -274,6 +274,12 @@ def select_remote_exec_target_sid(context_id: str) -> str | None:
274 fallback_sid = sid
275 continue
276 if metadata.enabled:
277 + if require_writes:
278 + file_metadata = _sid_remote_file_metadata.get(sid)
279 + if file_metadata is not None and (
280 + not file_metadata.enabled or not file_metadata.write_enabled
281 + ):
282 + continue
283 return sid
284 return fallback_sid
285
plugins/_a0_connector/prompts/agent.extras.code_execution_remote.md
+7 -14
@@ -1,6 +1,7 @@
1 ## code_execution_remote guidance
2
3 Remote code execution is currently available in this context through the connected CLI.
4 +Current local access mode: `{{access_mode}}`
5
6 Execution config:
7 - code execution timeouts: `{{code_exec_timeouts}}`
@@ -16,6 +17,7 @@ Execution config:
17 - `runtime=input` is only a deprecated compatibility alias for sending one line of keyboard input into a running shell session.
18 - Frontend execution may still be locally disabled in the CLI session. If so, expect a structured `{ok: false}` error instead of a fallback runtime.
19 - Prefer concise, self-checking commands. For multi-step work, inspect output and continue in the same session instead of restarting from scratch.
20 +{{write_runtime_guidance}}
21
22 Examples:
23
@@ -23,9 +25,8 @@ Examples:
25 {
26 "tool_name": "code_execution_remote",
27 "tool_args": {
26 - "runtime": "terminal",
27 - "session": 0,
28 - "code": "pwd && ls -la"
28 + "runtime": "output",
29 + "session": 0
30 }
31 }
32 ```
@@ -34,19 +35,11 @@ Examples:
35 {
36 "tool_name": "code_execution_remote",
37 "tool_args": {
37 - "runtime": "python",
38 + "runtime": "reset",
39 "session": 0,
39 - "code": "import os\nprint(os.getcwd())"
40 + "reason": "Start a clean shell for the next step."
41 }
42 }
43 ```
44
44 -```json
45 -{
46 - "tool_name": "code_execution_remote",
47 - "tool_args": {
48 - "runtime": "output",
49 - "session": 0
50 - }
51 -}
52 -```
45 +{{write_runtime_examples}}
plugins/_a0_connector/prompts/agent.system.tool.code_execution_remote.md
+4
@@ -9,6 +9,9 @@ subscribed CLI, so the base system prompt stays small when remote execution is n
9 - The CLI client must support `connector_exec_op`.
10 - Frontend execution may be locally disabled in the CLI session; in that case the result is
11 a structured `{ok: false}` error and no fallback runtime is used.
12 +- Mutating runtimes (`terminal`, `python`, `nodejs`, and `input`) also require the CLI
13 + session to advertise local access mode `Read&Write` via F3. `output` and `reset` can
14 + still be used for existing sessions while the CLI is in `Read only`.
15
16 ## Arguments
17 - `runtime`: one of `terminal`, `python`, `nodejs`, `output`, `reset`
@@ -25,4 +28,5 @@ Runtime-specific fields:
28 - Session state is frontend-local and shell-backed.
29 - `output` is for long-running operations where a prior call returned control before the
30 shell reached a prompt.
31 +- Use shell syntax that matches the remote host (for example, PowerShell on Windows).
32 - The transport uses `connector_exec_op` and `connector_exec_op_result` with shared `op_id`.
plugins/_a0_connector/tools/code_execution_remote.py
+33 -2
@@ -11,6 +11,8 @@ from helpers.ws_manager import ConnectionNotFoundError, get_shared_ws_manager
11
12 from plugins._a0_connector.helpers.ws_runtime import (
13 clear_pending_exec_op,
14 + remote_exec_metadata_for_sid,
15 + remote_file_metadata_for_sid,
16 select_remote_exec_target_sid,
17 store_pending_exec_op,
18 subscribed_sids_for_context,
@@ -24,6 +26,10 @@ EXEC_OP_EVENT = "connector_exec_op"
26 class CodeExecutionRemote(Tool):
27 """Send shell-backed frontend execution operations to the connected CLI machine."""
28
29 + @staticmethod
30 + def _runtime_requires_write_access(runtime: str) -> bool:
31 + return runtime in {"terminal", "python", "nodejs", "input"}
32 +
33 def get_log_object(self):
34 import uuid
35
@@ -62,11 +68,36 @@ class CodeExecutionRemote(Tool):
68
69 context_id = self.agent.context.id
70 subscribers = subscribed_sids_for_context(context_id)
65 - sid = select_remote_exec_target_sid(context_id)
71 + require_writes = self._runtime_requires_write_access(runtime)
72 + sid = select_remote_exec_target_sid(context_id, require_writes=require_writes)
73 if not sid:
74 + exec_enabled = False
75 + write_blocked = False
76 + for subscriber_sid in subscribers:
77 + exec_metadata = remote_exec_metadata_for_sid(subscriber_sid)
78 + if exec_metadata is None:
79 + exec_enabled = True
80 + continue
81 + if not exec_metadata.get("enabled"):
82 + continue
83 + exec_enabled = True
84 + if not require_writes:
85 + break
86 + file_metadata = remote_file_metadata_for_sid(subscriber_sid)
87 + if file_metadata is not None and (
88 + not file_metadata.get("enabled", True)
89 + or not file_metadata.get("write_enabled")
90 + ):
91 + write_blocked = True
92 +
93 return Response(
94 message=(
69 - "code_execution_remote: no subscribed CLI in this context currently has "
95 + "code_execution_remote: no subscribed CLI in this context currently allows "
96 + "shell-backed execution that may modify local files. Press F3 to switch "
97 + "the CLI to Read&Write. `runtime=output` and `runtime=reset` remain "
98 + "available for existing sessions."
99 + if subscribers and require_writes and exec_enabled and write_blocked
100 + else "code_execution_remote: no subscribed CLI in this context currently has "
101 "remote execution enabled. Connect the CLI and press F4 to switch exec on."
102 if subscribers
103 else "code_execution_remote: no CLI client connected to this context. "