fix(security): harden python sandbox module blocklist (v2) (#2047)

* fix(security): harden python sandbox module blocklist

Add importlib, sys, io, pathlib, signal to blocked modules.
Prevents common sandbox bypass via importlib.import_module()
and sys.modules direct access.

Constraint: _sandbox_import checks top_level = name.split(".")[0], so submodules are automatically blocked
Rejected: Loading full bridge module in tests | bridge requires server env (_sandbox_enabled not yet defined)
Confidence: high
Scope-risk: narrow

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(test): remove get_sandbox_namespace from AST extraction

Only extract _sandbox_import for sandbox tests. get_sandbox_namespace
uses Dict type hint which fails on Python 3.14 without typing import.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(security): fix sandbox init order and add bypass/integration tests

Address review feedback:
- Move sandbox section before ExecutionState to fix initialization order
  (_sandbox_enabled must exist before ExecutionState() instantiation)
- Add tests for "from importlib import import_module" bypass path
- Add test for __import__("os") bypass via builtins replacement
- Add integration test loading real bridge module with sandbox enabled
- Document intentional tradeoff of blocking sys/io/pathlib

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(security): block dotted submodule imports in python sandbox

The import hook only checked `name.split(".")[0]` against the blocklist,
so dotted entries like `http.server` and `xmlrpc.server` were never
matched. Check the full module name as well.

Constraint: Must not block parent modules entirely (e.g. http.client is legitimate)
Confidence: high
Scope-risk: narrow

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(security): block fromlist-based submodule imports in python sandbox

`from http import server` bypassed the blocklist because Python calls
`__import__("http", fromlist=("server",))` — name is "http" which
isn't blocked. Now check fromlist entries against dotted blocklist
entries (e.g. "http" + "server" → "http.server" → blocked).

Non-blocked submodules like http.client remain allowed.

Confidence: high
Scope-risk: narrow

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: blue-int <blue-int@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
BLUE
2026-04-01 13:02:55 +09:00
committed by GitHub
parent 31f86e95f1
commit 4b169c333d
2 changed files with 224 additions and 53 deletions

View File

@@ -295,6 +295,79 @@ def clean_memory() -> Dict[str, float]:
return get_memory_usage()
# =============================================================================
# SANDBOX MODE
# =============================================================================
# Modules blocked in sandbox mode (system access, process spawning, networking).
# Note: sys, io, pathlib are intentionally blocked despite limiting some legitimate
# REPL usage — this is an acceptable tradeoff for defense-in-depth. This blocklist
# is not a security boundary on its own; OS-level isolation is recommended for
# untrusted code. The blocklist prevents common bypass patterns within the sandbox.
SANDBOX_BLOCKED_MODULES = frozenset(
{
"os",
"subprocess",
"shutil",
"socket",
"ctypes",
"multiprocessing",
"webbrowser",
"http.server",
"xmlrpc.server",
# Bypass prevention
"importlib", # Prevents importlib.import_module('os') bypass
"sys", # Prevents sys.modules direct access bypass
"io", # Prevents file I/O bypass (complements open() block)
"pathlib", # Prevents filesystem access via Path objects
"signal", # Prevents signal handler manipulation
}
)
# Builtins removed in sandbox mode
SANDBOX_BLOCKED_BUILTINS = frozenset(
{"exec", "eval", "compile", "__import__", "open", "breakpoint"}
)
_sandbox_enabled = os.environ.get("OMC_PYTHON_SANDBOX") == "1"
_original_import = __builtins__.__import__ if hasattr(__builtins__, "__import__") else __import__
def _sandbox_import(name, *args, **kwargs):
"""Import hook that blocks dangerous modules in sandbox mode."""
top_level = name.split(".")[0]
if top_level in SANDBOX_BLOCKED_MODULES or name in SANDBOX_BLOCKED_MODULES:
raise ImportError(
f"Module '{name}' is blocked in sandbox mode. "
f"Disable sandbox via security.pythonSandbox in .claude/omc.jsonc or unset OMC_SECURITY."
)
# Check fromlist for dotted-module blocklist entries (e.g. "from http import server")
# __import__ signature: __import__(name, globals, locals, fromlist, level)
fromlist = (args[2] if len(args) > 2 else kwargs.get("fromlist")) or ()
for attr in fromlist:
qualified = f"{name}.{attr}"
if qualified in SANDBOX_BLOCKED_MODULES:
raise ImportError(
f"Module '{qualified}' is blocked in sandbox mode. "
f"Disable sandbox via security.pythonSandbox in .claude/omc.jsonc or unset OMC_SECURITY."
)
return _original_import(name, *args, **kwargs)
def get_sandbox_namespace() -> Dict[str, Any]:
"""Build a restricted builtins dict for sandbox mode."""
import builtins as _builtins_mod
safe_builtins = {
k: v
for k, v in vars(_builtins_mod).items()
if k not in SANDBOX_BLOCKED_BUILTINS
}
# Replace __import__ with the blocking version
safe_builtins["__import__"] = _sandbox_import
return {"__builtins__": safe_builtins}
# =============================================================================
# EXECUTION STATE
# =============================================================================
@@ -381,59 +454,6 @@ class ExecutionTimeoutError(Exception):
pass
# =============================================================================
# SANDBOX MODE
# =============================================================================
# Modules blocked in sandbox mode (system access, process spawning, networking)
SANDBOX_BLOCKED_MODULES = frozenset(
{
"os",
"subprocess",
"shutil",
"socket",
"ctypes",
"multiprocessing",
"webbrowser",
"http.server",
"xmlrpc.server",
}
)
# Builtins removed in sandbox mode
SANDBOX_BLOCKED_BUILTINS = frozenset(
{"exec", "eval", "compile", "__import__", "open", "breakpoint"}
)
_sandbox_enabled = os.environ.get("OMC_PYTHON_SANDBOX") == "1"
_original_import = __builtins__.__import__ if hasattr(__builtins__, "__import__") else __import__
def _sandbox_import(name, *args, **kwargs):
"""Import hook that blocks dangerous modules in sandbox mode."""
top_level = name.split(".")[0]
if top_level in SANDBOX_BLOCKED_MODULES:
raise ImportError(
f"Module '{name}' is blocked in sandbox mode. "
f"Disable sandbox via security.pythonSandbox in .claude/omc.jsonc or unset OMC_SECURITY."
)
return _original_import(name, *args, **kwargs)
def get_sandbox_namespace() -> Dict[str, Any]:
"""Build a restricted builtins dict for sandbox mode."""
import builtins as _builtins_mod
safe_builtins = {
k: v
for k, v in vars(_builtins_mod).items()
if k not in SANDBOX_BLOCKED_BUILTINS
}
# Replace __import__ with the blocking version
safe_builtins["__import__"] = _sandbox_import
return {"__builtins__": safe_builtins}
def _timeout_handler(signum, frame):
"""Signal handler for execution timeout."""
raise ExecutionTimeoutError("Code execution timed out")

View File

@@ -1,4 +1,8 @@
import { describe, it, expect, afterEach } from 'vitest';
import { execSync } from 'child_process';
import { writeFileSync, unlinkSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { isPythonSandboxEnabled, clearSecurityConfigCache } from '../../../lib/security-config.js';
describe('python-repl sandbox env propagation', () => {
@@ -25,3 +29,150 @@ describe('python-repl sandbox env propagation', () => {
expect(isPythonSandboxEnabled()).toBe(true);
});
});
// Helper: test sandbox import blocking by extracting only the relevant constants
// from gyoshu_bridge.py using AST parsing, avoiding full module initialization.
function executePythonInSandbox(code: string): string {
const bridgePath = new URL('../../../../bridge/gyoshu_bridge.py', import.meta.url).pathname;
const tmpScript = join(tmpdir(), `omc-sandbox-test-${Date.now()}.py`);
const escapedBridgePath = JSON.stringify(bridgePath);
const escapedCode = JSON.stringify(code);
const lines = [
'import ast, builtins',
`_src = open(${escapedBridgePath}).read()`,
'_tree = ast.parse(_src)',
'_globals = {"__builtins__": builtins, "frozenset": frozenset}',
'for _node in _tree.body:',
' if isinstance(_node, ast.Assign):',
' _targets = [t.id for t in _node.targets if isinstance(t, ast.Name)]',
' for _n in _targets:',
' if _n in ("SANDBOX_BLOCKED_MODULES", "SANDBOX_BLOCKED_BUILTINS", "_original_import"):',
' exec(compile(ast.Module(body=[_node], type_ignores=[]), "<bridge>", "exec"), _globals)',
' elif isinstance(_node, ast.FunctionDef):',
' if _node.name == "_sandbox_import":',
' exec(compile(ast.Module(body=[_node], type_ignores=[]), "<bridge>", "exec"), _globals)',
'_sandbox_import = _globals["_sandbox_import"]',
'_blocked = _globals["SANDBOX_BLOCKED_BUILTINS"]',
'import builtins as _b',
'_safe = {k: v for k, v in vars(_b).items() if k not in _blocked}',
'_safe["__import__"] = _sandbox_import',
'ns = {"__builtins__": _safe}',
'try:',
` exec(compile(${escapedCode}, "<sandbox>", "exec"), ns)`,
' print("ok")',
'except ImportError as e:',
' print(str(e))',
'except Exception as e:',
' print(f"error: {e}")',
];
writeFileSync(tmpScript, lines.join('\n'), 'utf-8');
try {
return execSync(`python3 ${tmpScript}`, { timeout: 10000 }).toString().trim();
} catch (e: unknown) {
const err = e as { stdout?: Buffer; stderr?: Buffer };
return (err.stdout?.toString() ?? '') + (err.stderr?.toString() ?? '');
} finally {
try { unlinkSync(tmpScript); } catch { /* ignore */ }
}
}
describe('python-repl sandbox blocked modules (bypass prevention)', () => {
it('should block importlib (bypass prevention)', () => {
const result = executePythonInSandbox('import importlib');
expect(result).toContain('blocked in sandbox mode');
});
it('should block sys module access', () => {
const result = executePythonInSandbox('import sys');
expect(result).toContain('blocked in sandbox mode');
});
it('should block io module', () => {
const result = executePythonInSandbox('import io');
expect(result).toContain('blocked in sandbox mode');
});
it('should block pathlib module', () => {
const result = executePythonInSandbox('import pathlib');
expect(result).toContain('blocked in sandbox mode');
});
it('should block signal module', () => {
const result = executePythonInSandbox('import signal');
expect(result).toContain('blocked in sandbox mode');
});
it('should block "from importlib import import_module" bypass', () => {
const result = executePythonInSandbox('from importlib import import_module');
expect(result).toContain('blocked in sandbox mode');
});
it('should block __import__("os") bypass via builtins removal', () => {
// __import__ is replaced with _sandbox_import in the sandbox namespace,
// so __import__("os") goes through the blocking hook
const result = executePythonInSandbox('__import__("os")');
expect(result).toContain('blocked in sandbox mode');
});
it('should block dotted submodule imports (http.server)', () => {
const result = executePythonInSandbox('import http.server');
expect(result).toContain('blocked in sandbox mode');
});
it('should block "from http.server import HTTPServer" bypass', () => {
const result = executePythonInSandbox('from http.server import HTTPServer');
expect(result).toContain('blocked in sandbox mode');
});
it('should block xmlrpc.server submodule import', () => {
const result = executePythonInSandbox('import xmlrpc.server');
expect(result).toContain('blocked in sandbox mode');
});
it('should block "from http import server" fromlist bypass', () => {
const result = executePythonInSandbox('from http import server');
expect(result).toContain('blocked in sandbox mode');
});
it('should block "from xmlrpc import server" fromlist bypass', () => {
const result = executePythonInSandbox('from xmlrpc import server');
expect(result).toContain('blocked in sandbox mode');
});
it('should allow non-blocked submodules (http.client)', () => {
const result = executePythonInSandbox('import http.client');
expect(result).toBe('ok');
});
});
describe('python-repl sandbox bridge startup integration', () => {
it('should load bridge with OMC_PYTHON_SANDBOX=1 and block os in sandbox namespace', () => {
const bridgePath = new URL('../../../../bridge/gyoshu_bridge.py', import.meta.url).pathname;
const tmpScript = join(tmpdir(), `omc-sandbox-bridge-${Date.now()}.py`);
const escapedPath = JSON.stringify(bridgePath);
// Load bridge as a module (not exec) with sandbox enabled,
// then verify code in sandbox namespace can't import os
const script = [
'import os, importlib.util',
'os.environ["OMC_PYTHON_SANDBOX"] = "1"',
`spec = importlib.util.spec_from_file_location("gyoshu_bridge", ${escapedPath})`,
'mod = importlib.util.module_from_spec(spec)',
'spec.loader.exec_module(mod)',
'# Verify sandbox namespace blocks dangerous imports',
'ns = mod.get_sandbox_namespace()',
'try:',
' exec("import os", ns)',
' print("FAIL: os imported in sandbox")',
'except ImportError as e:',
' print(f"PASS: {e}")',
].join('\n');
writeFileSync(tmpScript, script, 'utf-8');
try {
const result = execSync(`python3 ${tmpScript} 2>&1`, { timeout: 10000 }).toString().trim();
expect(result).toContain('PASS:');
expect(result).toContain('blocked in sandbox mode');
} finally {
try { unlinkSync(tmpScript); } catch { /* ignore */ }
}
});
});