mirror of
https://fastgit.cc/github.com/Yeachan-Heo/oh-my-claudecode
synced 2026-04-20 21:00:50 +08:00
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:
@@ -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")
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user