Skip to content

Commit 4daa7f4

Browse files
committed
[GR-76680] GraalPy GraalOS standalone testing and fixes.
PullRequest: graalpython/4671
2 parents 25b2223 + b661629 commit 4daa7f4

15 files changed

Lines changed: 1016 additions & 64 deletions

ci.jsonnet

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
(import "ci/python-gate.libsonnet") +
66
(import "ci/python-bench.libsonnet") +
77
{
8-
overlay: "26571215e27b3c415afb8119d38a0418c14b29c9",
8+
overlay: "12367561e6a2b54df8d3b1bd400e431de01eef0f",
99
specVersion: "8",
1010
// Until buildbot issues around CI tiers are resolved, we cannot use them
1111
// tierConfig: self.tierConfig,
@@ -310,15 +310,17 @@
310310
"tox-example": gpgate_ee + require(GPYEE_NATIVE_STANDALONE) + platform_spec(no_jobs) + platform_spec({
311311
"linux:amd64:jdk-latest" : tier3,
312312
}),
313-
// "python-svm-graalos-standalone-build": gpgate_ee + internet_access_env + platform_spec(no_jobs) + platform_spec({
314-
// "linux:amd64:jdk-latest": tier3 + $.ol8 + task_spec({
315-
// environment +: {
316-
// GRAALPY_GRAALOS_TOOLCHAIN_URL: $.overlay_imports.GRAALPY_GRAALOS_TOOLCHAIN_URL,
317-
// GRAALPY_GRAALOS_RUNTIME_URL: $.overlay_imports.GRAALPY_GRAALOS_RUNTIME_URL,
318-
// GRAALPY_GRAALOS_ARTIFACT_BASE_URL: $.overlay_imports.GRAALPY_GRAALOS_ARTIFACT_BASE_URL,
319-
// },
320-
// }),
321-
// }),
313+
"python-svm-graalos-standalone-build": gpgate_ee + internet_access_env + platform_spec(no_jobs) + platform_spec({
314+
"linux:amd64:jdk-latest": tier3 + $.ol8 + task_spec({
315+
capabilities+: ["mpk", "!fast", "!x82", "!x82_16_367"],
316+
deploysArtifacts: true,
317+
environment +: {
318+
GRAALPY_GRAALOS_TOOLCHAIN_URL: $.overlay_imports.GRAALPY_GRAALOS_TOOLCHAIN_URL,
319+
GRAALPY_GRAALOS_RUNTIME_URL: $.overlay_imports.GRAALPY_GRAALOS_RUNTIME_URL,
320+
GRAALPY_GRAALOS_ARTIFACT_BASE_URL: $.overlay_imports.GRAALPY_GRAALOS_ARTIFACT_BASE_URL,
321+
},
322+
}),
323+
}),
322324
},
323325

324326
local need_pgo = task_spec({runAfter: ["python-pgo-profile-post_merge-linux-amd64-jdk-latest"]}),
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# GraalOS Standalone Sandbox Demo
2+
3+
This demo shows a small chat-style Python evaluator running inside the
4+
GraalPy GraalOS standalone.
5+
6+
The story:
7+
8+
1. `rich` renders a friendly terminal UI.
9+
2. The demo treats each entered expression as untrusted Python code, such as
10+
code produced by an LLM agent or pasted by a human operator.
11+
3. The process is inside the GraalOS sandbox, so file, subprocess,
12+
network, and native library attempts remain contained.
13+
14+
## Setup
15+
16+
We can install the `rich` wheel directly into the standalone's `site-packages`
17+
using any standard Python. While we could run `ensurepip` and `pip` inside the
18+
sandbox by configuring the appropriate network access, we do this here
19+
intentionally done outside the sandbox. The sandboxed standalone has no
20+
outbound network mapping by default, which is one of the things the demo can
21+
show.
22+
23+
```bash
24+
python3 -m pip install \
25+
--target GRAALPY_NATIVE_GRAALOS_STANDALONE/lib/python3.12/site-packages \
26+
--only-binary=:all: \
27+
--python-version 3.12 \
28+
--implementation py --implementation graalpy \
29+
--abi none --abi graalpy250_312_native \
30+
--platform any --platform graalos_x86_64 \
31+
--no-compile \
32+
rich
33+
```
34+
35+
There should be a file `test_graalos_sandbox_chat.py` in this directory. If
36+
not, find it in and copy it from the GraalPy source repository. From inside the
37+
sandbox that file is available as `/test_graalos_sandbox_chat.py`, so run:
38+
39+
```bash
40+
./bin/graalpy /test_graalos_sandbox_chat.py
41+
```
42+
43+
For a non-interactive walkthrough:
44+
45+
```bash
46+
./bin/graalpy /test_graalos_sandbox_chat.py --demo
47+
```
48+
49+
## Demo Beats
50+
51+
Start with a normal expression:
52+
53+
```python
54+
sum([i*i for i in range(1000)])
55+
```
56+
57+
Then move on to untrusted code that tries to access host resources:
58+
59+
```python
60+
open('/etc/passwd').read()
61+
open('/etc/passwd').read().splitlines()[:3]
62+
open('/etc/shadow').read()
63+
__import__('subprocess').run(['/bin/sh', '-c', 'id'], capture_output=True, text=True)
64+
__import__('socket').create_connection(('example.com', 80), timeout=2)
65+
__import__('ctypes').CDLL('libc.so').system(b'cat /etc/shadow')
66+
```
67+
68+
Expected result: harmless operations work or fail normally; sensitive host
69+
resources are unavailable because the process only sees the sandboxed virtual
70+
filesystem, process namespace, and configured network policy. The native
71+
`system()` probe returns `-1`, which the demo renders as blocked.
72+
73+
## Why This Is Useful
74+
75+
This is a deliberately unsafe application pattern: it evaluates untrusted Python
76+
code directly. That is useful for demonstrating the actual containment boundary.
77+
GraalOS is that boundary, and it mediates filesystem, subprocess, native, and
78+
network behavior even when the application itself offers no extra guardrails.
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
#!/usr/bin/env python3
2+
# Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved.
3+
# DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
4+
#
5+
# The Universal Permissive License (UPL), Version 1.0
6+
#
7+
# Subject to the condition set forth below, permission is hereby granted to any
8+
# person obtaining a copy of this software, associated documentation and/or
9+
# data (collectively the "Software"), free of charge and under any and all
10+
# copyright rights in the Software, and any and all patent rights owned or
11+
# freely licensable by each licensor hereunder covering either (i) the
12+
# unmodified Software as contributed to or provided by such licensor, or (ii)
13+
# the Larger Works (as defined below), to deal in both
14+
#
15+
# (a) the Software, and
16+
#
17+
# (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if
18+
# one is included with the Software each a "Larger Work" to which the Software
19+
# is contributed by such licensors),
20+
#
21+
# without restriction, including without limitation the rights to copy, create
22+
# derivative works of, display, perform, and distribute the Software and make,
23+
# use, sell, offer for sale, import, export, have made, and have sold the
24+
# Software and the Larger Work(s), and to sublicense the foregoing rights on
25+
# either these or other terms.
26+
#
27+
# This license is subject to the following condition:
28+
#
29+
# The above copyright notice and either this complete permission notice or at a
30+
# minimum a reference to the UPL must be included in all copies or substantial
31+
# portions of the Software.
32+
#
33+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
34+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
35+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
36+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
37+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
38+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
39+
# SOFTWARE.
40+
"""Small Rich demo for the GraalOS standalone sandbox."""
41+
42+
from __future__ import annotations
43+
44+
import argparse
45+
import io
46+
import sysconfig
47+
import textwrap
48+
import time
49+
import unittest
50+
from dataclasses import dataclass
51+
52+
53+
console = None
54+
55+
56+
@dataclass
57+
class EvalResult:
58+
mode: str
59+
ok: bool
60+
output: str
61+
elapsed_ms: float
62+
def render_message(role: str, body: str, style: str) -> None:
63+
from rich.panel import Panel
64+
from rich.text import Text
65+
console.print(Panel(Text(body), title=role, title_align="left", border_style=style))
66+
67+
68+
def unsafe_eval(expr: str):
69+
start = time.perf_counter()
70+
try:
71+
value = eval(expr)
72+
elapsed = (time.perf_counter() - start) * 1000
73+
if value == -1:
74+
return EvalResult("python eval", False, "-1 (operation denied by sandbox/runtime)", elapsed)
75+
return EvalResult("python eval", True, repr(value), elapsed)
76+
except Exception as exc:
77+
elapsed = (time.perf_counter() - start) * 1000
78+
return EvalResult("python eval", False, f"{type(exc).__name__}: {exc}", elapsed)
79+
80+
81+
def render_result(result) -> None:
82+
from rich.table import Table
83+
table = Table.grid(padding=(0, 1))
84+
table.add_column(style="bold")
85+
table.add_column()
86+
table.add_row("mode", result.mode)
87+
table.add_row("status", "[green]ok[/green]" if result.ok else "[red]blocked/error[/red]")
88+
table.add_row("time", f"{result.elapsed_ms:.1f} ms")
89+
console.print(table)
90+
render_message("sandbox", result.output, "green" if result.ok else "red")
91+
92+
93+
def evaluate(line: str) -> None:
94+
line = line.strip()
95+
if not line:
96+
return
97+
render_result(unsafe_eval(line))
98+
99+
100+
def demo_script() -> list[str]:
101+
return [
102+
"sum([i*i for i in range(1000)])",
103+
"sin(pi / 4) ** 2 + cos(pi / 4) ** 2",
104+
"open('/etc/passwd').read()",
105+
"open('/etc/passwd').read().splitlines()[:3]",
106+
"open('/etc/shadow').read()",
107+
"__import__('subprocess').run(['/bin/sh', '-c', 'id'], capture_output=True, text=True)",
108+
"__import__('socket').create_connection(('example.com', 80), timeout=2)",
109+
"__import__('ctypes').CDLL('libc.so').system(b'cat /etc/shadow')",
110+
]
111+
112+
113+
def print_intro() -> None:
114+
body = textwrap.dedent(
115+
"""
116+
Type Python expressions and get chat-style results.
117+
118+
This demo treats each expression as untrusted Python code, such as
119+
code proposed by an LLM agent or pasted by a human operator.
120+
GraalOS sandboxes that code, so filesystem, subprocess, native
121+
library, and network attempts remain contained.
122+
123+
Commands: /demo, /help, /quit
124+
"""
125+
).strip()
126+
render_message("graalos sandbox chat", body, "cyan")
127+
128+
129+
def print_help() -> None:
130+
examples = "\n".join(demo_script())
131+
from rich.syntax import Syntax
132+
console.print(Syntax(examples, "python", theme="ansi_dark", word_wrap=True))
133+
134+
135+
def interactive() -> int:
136+
print_intro()
137+
while True:
138+
try:
139+
line = console.input("[bold cyan]you>[/bold cyan] ")
140+
except (EOFError, KeyboardInterrupt):
141+
console.print()
142+
return 0
143+
command = line.strip()
144+
if command in {"/quit", "/exit"}:
145+
return 0
146+
if command == "/help":
147+
print_help()
148+
continue
149+
if command == "/demo":
150+
run_demo()
151+
continue
152+
evaluate(line)
153+
154+
155+
def run_demo() -> None:
156+
for line in demo_script():
157+
render_message("you", line, "blue")
158+
evaluate(line)
159+
160+
161+
def main(argv: list[str] | None = None) -> int:
162+
from rich.console import Console
163+
global console
164+
if console is None:
165+
console = Console()
166+
parser = argparse.ArgumentParser()
167+
parser.add_argument("--demo", action="store_true", help="run the prepared demo script and exit")
168+
args = parser.parse_args(argv)
169+
170+
if args.demo:
171+
print_intro()
172+
run_demo()
173+
return 0
174+
return interactive()
175+
176+
177+
def skip_unless_graalos():
178+
soabi = sysconfig.get_config_var("SOABI") or ""
179+
if "graalos" not in soabi:
180+
raise unittest.SkipTest(f"requires GraalOS SOABI, got {soabi!r}")
181+
182+
183+
class GraalOSSandboxChatTests(unittest.TestCase):
184+
185+
def setUp(self):
186+
skip_unless_graalos()
187+
188+
def test_demo_packages(self):
189+
import rich
190+
191+
self.assertTrue(rich.get_console())
192+
193+
def test_sandbox_chat_demo(self):
194+
from rich.console import Console
195+
global console
196+
output = io.StringIO()
197+
console = Console(file=output, force_terminal=False, color_system=None, width=120)
198+
self.assertEqual(main(["--demo"]), 0)
199+
stdout = output.getvalue()
200+
self.assertIn("sum([i*i for i in range(1000)])", stdout)
201+
self.assertIn("__import__('socket').create_connection", stdout)
202+
self.assertIn("gaierror", stdout)
203+
self.assertIn("FileNotFoundError", stdout)
204+
self.assertIn("operation denied", stdout)
205+
206+
207+
if __name__ == "__main__":
208+
raise SystemExit(main())

graalpython/com.oracle.graal.python.test/src/tests/test_graalos_standalone.py renamed to graalpython/com.oracle.graal.python.test/src/tests/graalos/test_graalos_standalone.py

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -41,19 +41,26 @@
4141
import unittest
4242

4343

44-
def test_graalos_sqlite3_native_extension_smoke():
44+
def skip_unless_graalos():
4545
soabi = sysconfig.get_config_var("SOABI") or ""
4646
if "graalos" not in soabi:
4747
raise unittest.SkipTest(f"requires GraalOS SOABI, got {soabi!r}")
4848

49-
import _sqlite3
50-
import sqlite3
51-
52-
assert _sqlite3.sqlite_version
53-
conn = sqlite3.connect(":memory:")
54-
try:
55-
conn.execute("create table values_for_sum(value integer)")
56-
conn.executemany("insert into values_for_sum(value) values (?)", [(1,), (2,), (3,)])
57-
assert conn.execute("select sum(value) from values_for_sum").fetchone()[0] == 6
58-
finally:
59-
conn.close()
49+
50+
class GraalOSStandaloneTests(unittest.TestCase):
51+
52+
def setUp(self):
53+
skip_unless_graalos()
54+
55+
def test_sqlite3_native_extension_smoke(self):
56+
import _sqlite3
57+
import sqlite3
58+
59+
self.assertTrue(_sqlite3.sqlite_version)
60+
conn = sqlite3.connect(":memory:")
61+
try:
62+
conn.execute("create table values_for_sum(value integer)")
63+
conn.executemany("insert into values_for_sum(value) values (?)", [(1,), (2,), (3,)])
64+
self.assertEqual(conn.execute("select sum(value) from values_for_sum").fetchone()[0], 6)
65+
finally:
66+
conn.close()

graalpython/graalpy_graalos_standalone_payload/CMakeLists.txt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ file(REMOVE_RECURSE
4949
"${PAYLOAD_DIR}/bin"
5050
"${PAYLOAD_DIR}/libexec"
5151
"${PAYLOAD_DIR}/lib"
52-
"${PAYLOAD_DIR}/config.json")
52+
"${PAYLOAD_DIR}/config.json"
53+
"${PAYLOAD_DIR}/README_GRAALOS_STANDALONE.md")
5354
file(MAKE_DIRECTORY
5455
"${PAYLOAD_DIR}/bin"
5556
"${PAYLOAD_DIR}/libexec"
@@ -169,6 +170,7 @@ _write_launcher("${PAYLOAD_DIR}/bin/${GRAALPY_CONFIG_LAUNCHER}" "/bin/graalpy-co
169170
_write_launcher("${PAYLOAD_DIR}/libexec/${GRAALPY_POLYGLOT_GET_LAUNCHER}" "/libexec/graalpy-polyglot-get")
170171

171172
_copy_file("${CMAKE_CURRENT_LIST_DIR}/config.json" "${PAYLOAD_DIR}/config.json")
173+
_copy_file("${CMAKE_CURRENT_LIST_DIR}/README_GRAALOS_STANDALONE.md" "${PAYLOAD_DIR}/README_GRAALOS_STANDALONE.md")
172174
_copy_executable("${CMAKE_CURRENT_LIST_DIR}/graalpy-sandbox-launcher.sh" "${GRAALOS_DIR}/graalpy-sandbox-launcher")
173175
_copy_executable(
174176
"${CMAKE_CURRENT_LIST_DIR}/graalpy-sandbox-expand-config.sh"

0 commit comments

Comments
 (0)