-
Notifications
You must be signed in to change notification settings - Fork 1.4k
fix(sandbox): use base64+stdin for edit() to avoid bash -lc escaping … #3084
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
b00209d
78df852
45cb3f0
a051736
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -255,30 +255,50 @@ 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 written to a temp file first (to avoid ARG_MAX limits), | ||
| // then passed as 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 temp file + pipe avoids all quoting/escaping issues and ARG_MAX limits. | ||
| String script = | ||
| "import sys, os, base64, json\n" | ||
| + "payload = json.loads(base64.b64decode(open(sys.argv[1]," | ||
| + " \"rb\").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" | ||
| + "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" | ||
| + " 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)); | ||
|
|
||
| String cmd = | ||
| "cat > /tmp/.agentscope-edit-$$ <<'__EDIT_EOF__'\n" | ||
| + payloadB64 | ||
| + "\n__EDIT_EOF__\n"; | ||
| + "\n__EDIT_EOF__\n" | ||
| + "printf '%s\\n' " | ||
| + scriptB64 | ||
| + " | base64 -d | python3 - /tmp/.agentscope-edit-$$ 2>&1\n" | ||
| + "rm -f /tmp/.agentscope-edit-$$"; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The payload lands in |
||
|
|
||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| ExecuteResponse result = execute(runtimeContext, cmd, null); | ||
| String output = result.output() != null ? result.output().strip() : ""; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -264,6 +264,57 @@ 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 temp file + printf + base64 pipe (not echo/argv) | ||
| assertTrue( | ||
| fs.lastCommand.contains("cat > /tmp/.agentscope-edit-$$"), | ||
| "edit should write payload to temp file, got: " + fs.lastCommand); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These new assertions still only check the string shape, which is why the previous revision passed CI while being fully broken at runtime — the |
||
| assertTrue( | ||
| fs.lastCommand.contains("printf '%s\\n'"), | ||
| "edit should use printf for script base64, got: " + fs.lastCommand); | ||
| assertTrue( | ||
| fs.lastCommand.contains("base64 -d | python3 - /tmp/.agentscope-edit-$$"), | ||
| "edit should pipe script and pass temp file as argv, got: " + fs.lastCommand); | ||
| assertTrue( | ||
| fs.lastCommand.contains("<<'__EDIT_EOF__'"), | ||
| "edit should use heredoc to write payload, got: " + fs.lastCommand); | ||
| assertFalse( | ||
| fs.lastCommand.contains("python3 -c"), | ||
| "edit should NOT use python3 -c inline form"); | ||
| assertFalse( | ||
| fs.lastCommand.contains("echo "), | ||
| "edit should NOT use echo (may wrap long base64), got: " + fs.lastCommand); | ||
| } | ||
|
|
||
| @Test | ||
| void edit_largePayloadUsesHeredocNotArgv() { | ||
| FakeSandboxFilesystem fs = new FakeSandboxFilesystem(); | ||
|
|
||
| // Simulate a large edit (multi-KB payload) to verify it goes through | ||
| // temp file (via heredoc), not argv[1] (which would hit ARG_MAX limits). | ||
| String largeString = "x".repeat(100_000); | ||
| fs.edit(RT, "/workspace/large.txt", "old", largeString, false); | ||
|
|
||
| // Payload must NOT appear directly as argv[1] | ||
| // It should be written to temp file via heredoc | ||
| assertTrue( | ||
| fs.lastCommand.contains("<<'__EDIT_EOF__'"), | ||
| "large payload should use heredoc to write temp file, got command length: " | ||
| + fs.lastCommand.length()); | ||
| // Verify the payload is after the heredoc marker, not before it (i.e., not in argv) | ||
| int heredocPos = fs.lastCommand.indexOf("<<'__EDIT_EOF__'"); | ||
| int editEofPos = fs.lastCommand.indexOf("__EDIT_EOF__", heredocPos + 1); | ||
| assertTrue(editEofPos > heredocPos, "payload should be between heredoc markers"); | ||
| } | ||
| } | ||
|
|
||
| // ================================================================ | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Info]
echois only guaranteed to pass a single long argument through verbatim by bash's builtin; someechoimplementations/versions wrap very long output, and a wrapped base64 stream decodes to a truncated script (manifesting as a PythonSyntaxError, i.e. the same symptom this PR is fixing). This repo already hit the mirror-image problem on the read path — seeSandboxBackedFilesystem#L209("MIME decoder tolerates wrapped base64 output from GNUbase64").printf '%s\n'avoids the ambiguity, and it would be worth extendingedit_usesBase64PipedScriptwith a large-payload case asserting the emitted command is a single line.