Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -255,30 +255,41 @@ public EditResult edit(
Base64.getEncoder()
.encodeToString(payload.getBytes(java.nio.charset.StandardCharsets.UTF_8));

String cmd =
"python3 -c \"import sys, os, base64, json\\n"
+ "payload ="
+ " json.loads(base64.b64decode(sys.stdin.read().strip()).decode('utf-8'))\\n"
+ "path, old, new = payload['path'], payload['old'], payload['new']\\n"
+ "replace_all = payload.get('replace_all', False)\\n"
+ "if not os.path.isfile(path):\\n"
+ " print(json.dumps({'error': 'file_not_found'}))\\n"
+ " sys.exit(0)\\n"
+ "with open(path, 'rb') as f: text = f.read().decode('utf-8')\\n"
+ "count = text.count(old)\\n"
+ "if count == 0:\\n"
+ " print(json.dumps({'error': 'string_not_found'}))\\n"
+ " sys.exit(0)\\n"
+ "if count > 1 and not replace_all:\\n"
+ " print(json.dumps({'error': 'multiple_occurrences', 'count': count}))\\n"
+ " sys.exit(0)\\n"
// Edit script is assembled with real newlines, then base64-encoded and piped via stdin
// to `python3 -`; the payload is passed through argv[1].
//
// Do NOT revert to `python3 -c "...\n..."` inline form: under `bash -lc`, \n inside
// double quotes is a literal backslash+n (not a newline), so the entire script collapses
// into one line and Python fails with:
// SyntaxError: unexpected character after line continuation character
// This makes edit_file 100% non-functional in sandbox environments (the model falls back
// to write_file, which refuses to overwrite existing files, so no file can be modified).
// Using stdin + argv avoids all quoting/escaping issues.
String script =
"import sys, os, base64, json\n"
+ "payload = json.loads(base64.b64decode(sys.argv[1]).decode('utf-8'))\n"
+ "path, old, new = payload['path'], payload['old'], payload['new']\n"
+ "replace_all = payload.get('replace_all', False)\n"
+ "if not os.path.isfile(path):\n"
+ " print(json.dumps({'error': 'file_not_found'}))\n"
+ " sys.exit(0)\n"
+ "with open(path, 'rb') as f: text = f.read().decode('utf-8')\n"
+ "count = text.count(old)\n"
+ "if count == 0:\n"
+ " print(json.dumps({'error': 'string_not_found'}))\n"
+ " sys.exit(0)\n"
+ "if count > 1 and not replace_all:\n"
+ " print(json.dumps({'error': 'multiple_occurrences', 'count': count}))\n"
+ " sys.exit(0)\n"
+ "result = text.replace(old, new) if replace_all else text.replace(old, new,"
+ " 1)\\n"
+ "with open(path, 'wb') as f: f.write(result.encode('utf-8'))\\n"
+ "print(json.dumps({'count': count}))\\n"
+ "\" 2>&1 <<'__EDIT_EOF__'\n"
+ payloadB64
+ "\n__EDIT_EOF__\n";
+ " 1)\n"
+ "with open(path, 'wb') as f: f.write(result.encode('utf-8'))\n"
+ "print(json.dumps({'count': count}))\n";
String scriptB64 =
Base64.getEncoder()
.encodeToString(script.getBytes(java.nio.charset.StandardCharsets.UTF_8));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Info] echo is only guaranteed to pass a single long argument through verbatim by bash's builtin; some echo implementations/versions wrap very long output, and a wrapped base64 stream decodes to a truncated script (manifesting as a Python SyntaxError, i.e. the same symptom this PR is fixing). This repo already hit the mirror-image problem on the read path — see SandboxBackedFilesystem#L209 ("MIME decoder tolerates wrapped base64 output from GNU base64"). printf '%s\n' avoids the ambiguity, and it would be worth extending edit_usesBase64PipedScript with a large-payload case asserting the emitted command is a single line.


String cmd = "echo " + scriptB64 + " | base64 -d | python3 - " + payloadB64 + " 2>&1";

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Warning] The fix moves the payload from stdin to argv[1], which reintroduces the two limits the original heredoc was protecting against:

  1. ARG_MAXold/new are arbitrary model-generated strings (whole-file edits are common), so the quoted form was unbounded-safe while python3 - <payloadB64> is not. On executors with a small per-request limit (E2B / Docker sh -c wrappers, Windows CreateProcess 32k) a large edit now fails at the shell instead of at the Python layer.
  2. /proc/<pid>/cmdline — argv is readable through ps for the lifetime of the process, so the file content being edited is briefly visible to anything else running in the sandbox. stdin is not.

Suggested shape that keeps the newline fix and the previous safety properties: keep the base64 script in argv (it is constant-size and contains no user data) and put only the payload back on stdin, e.g.

String cmd = "echo " + scriptB64 + " | base64 -d | python3 - 2>&1 <<'__EDIT_EOF__'\n"
        + payloadB64 + "\n__EDIT_EOF__\n";

(with payload = json.loads(base64.b64decode(sys.stdin.read().strip())) restored in the script), or write both to a temp file via a single quoted heredoc.


Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The base64 alphabet is newline-free, so the quoted heredoc delimiter can never be terminated early by the payload — that part is safe. What is not safe is the error path: if the temp file ever goes missing or base64 -d fails, the 2>&1 redirect puts a raw Python traceback into output, which matches neither the "error" nor the "count" branch and falls through to the generic "unexpected server response" message. That is exactly how the previous revision's breakage surfaced, so a cheap guard (detect Traceback / No such file or directory in the output and report an execution failure rather than a model-visible edit result) would make the next regression obvious instead of mysterious.

ExecuteResponse result = execute(runtimeContext, cmd, null);
String output = result.output() != null ? result.output().strip() : "";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,24 @@ void glob_executeFailure_shouldFailInsteadOfErrorAsPaths() {
assertFalse(result.isSuccess(), "glob should fail when the command never ran");
assertTrue(result.error().contains("status=504"), "error should carry the cause");
}

@Test
void edit_usesBase64PipedScript() {
FakeSandboxFilesystem fs = new FakeSandboxFilesystem();

// edit() constructs the command and calls execute().
// FakeSandboxFilesystem.execute() returns empty output, so edit() will
// return an error result, but we only care about verifying the command shape.
var result = fs.edit(RT, "/workspace/test.txt", "old", "new", false);

// Verify command uses base64 pipe mode (not python3 -c inline form)
assertTrue(
fs.lastCommand.contains("base64 -d | python3 -"),
"edit should use base64 piped to python3, got: " + fs.lastCommand);
assertFalse(
fs.lastCommand.contains("python3 -c"),
"edit should NOT use python3 -c inline form");
}
}

// ================================================================
Expand Down
Loading