[eric] outputs: HITL preview for risky backend.py + workspace path-traversal hardening

This commit is contained in:
ciregenz
2026-05-13 10:50:15 -07:00
parent 1b0e6c35eb
commit afe11b6f37
8 changed files with 190 additions and 38 deletions
+4
View File
@@ -2,6 +2,10 @@
FastAPI orchestrator. Entry: `backend/main.py` (uvicorn `:8324`, REST `/api/*`, WS `/ws/*`, Swagger `/docs`). See root `CLAUDE.md` for repo-wide constraints.
## Coding precedences
Full precedences live in root [CLAUDE.md](../.claude/CLAUDE.md). Always: **understand the end goal before coding** (what does the user actually need?); **reuse before you write** (grep existing routes / SubApps / helpers — most needs already have one); ~300 LOC/file ceiling; downward-tree imports; no comments except WHY-non-obvious; test after meaningful changes; weigh speed, efficiency, robustness, UX, security on every change.
## Run / test
- Dev: `bash backend/run.sh` (creates `.venv/`, installs `requirements.txt`, runs uvicorn with `--reload`).
+49 -14
View File
@@ -39,33 +39,61 @@ class UnsafeCodeError(Exception):
"""Raised when AST validation rejects user-supplied backend code."""
def _validate_code_safety(code: str) -> None:
def get_code_warnings(code: str) -> list[str]:
"""Return human-readable warnings for AST-visible risks, without raising.
Used by `/api/outputs/execute` to surface risks to the user in the run
dialog before executing — so a legit Output that needs `pandas` doesn't
silently 500 with "import not allowed," it gets a "this Output uses
unsafe imports — review and click Run Anyway" affordance.
Returns [] for code that's fully inside the allowlist. A syntax error
is reported as a single warning rather than raised so the dialog can
show it next to the code.
"""
try:
tree = ast.parse(code)
except SyntaxError as e:
raise UnsafeCodeError(f"Backend code has a syntax error: {e}")
return [f"Syntax error: {e}"]
warnings: list[str] = []
seen: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
root = alias.name.split(".")[0]
if root not in _ALLOWED_MODULES:
raise UnsafeCodeError(
f"import of '{alias.name}' is not allowed in backend code "
f"(allowed: {sorted(_ALLOWED_MODULES)})"
)
msg = f"Imports '{alias.name}' (outside the safe-data-shaping allowlist)"
if msg not in seen:
seen.add(msg)
warnings.append(msg)
elif isinstance(node, ast.ImportFrom):
if node.module:
root = node.module.split(".")[0]
if root not in _ALLOWED_MODULES:
raise UnsafeCodeError(
f"from '{node.module}' import ... is not allowed in backend code"
)
msg = f"Imports from '{node.module}' (outside the safe-data-shaping allowlist)"
if msg not in seen:
seen.add(msg)
warnings.append(msg)
elif isinstance(node, ast.Call):
if isinstance(node.func, ast.Name) and node.func.id in _BLOCKED_BUILTINS:
raise UnsafeCodeError(
f"call to builtin '{node.func.id}()' is not allowed in backend code"
)
msg = f"Calls builtin '{node.func.id}()' which can escape the sandbox"
if msg not in seen:
seen.add(msg)
warnings.append(msg)
return warnings
def _validate_code_safety(code: str) -> None:
"""Raise UnsafeCodeError on the first AST-visible risk. Thin wrapper
around get_code_warnings for callers that want the strict-reject
behavior (the default `execute_backend_code` path). Callers that want
to show warnings to a user and let them override should call
get_code_warnings directly and pass `skip_validation=True` to
execute_backend_code."""
warnings = get_code_warnings(code)
if warnings:
raise UnsafeCodeError(warnings[0])
def _minimal_env() -> dict:
@@ -95,7 +123,9 @@ class BackendExecResult:
stderr: str
async def execute_backend_code(code: str, input_data: dict) -> BackendExecResult:
async def execute_backend_code(
code: str, input_data: dict, *, skip_validation: bool = False
) -> BackendExecResult:
"""Execute user-provided Python code in a subprocess.
The code receives ``input_data`` as a global dict and must assign its
@@ -109,9 +139,14 @@ async def execute_backend_code(code: str, input_data: dict) -> BackendExecResult
4. Preamble scrubs dangerous attrs off `builtins` inside the subprocess
to catch AST-bypass tricks (e.g. metaclass shenanigans).
5. 30s wall-clock timeout, killed on overrun.
`skip_validation=True` bypasses #1 — intended ONLY for callers that
have already surfaced the warnings to a user and gotten explicit
consent (the `/api/outputs/execute` HITL flow). #2–#5 always run.
"""
_validate_code_safety(code)
if not skip_validation:
_validate_code_safety(code)
preamble = (
"import json, sys, io, builtins\n"
+13
View File
@@ -123,6 +123,14 @@ class OutputUpdate(BaseModel):
class OutputExecute(BaseModel):
output_id: str
input_data: dict[str, Any] = Field(default_factory=dict)
# When False (default), `/execute` returns AST warnings instead of
# running if the backend code touches anything outside the safe
# data-shaping allowlist. The UI shows those warnings to the user and
# re-submits with force=True after they click "Run Anyway." This is
# a UX gate, not a security one — anyone holding the auth token can
# set force=True; the value is providing the user explicit visibility
# of what's about to execute.
force: bool = False
class OutputExecuteResult(BaseModel):
@@ -134,6 +142,11 @@ class OutputExecuteResult(BaseModel):
stdout: Optional[str] = None
stderr: Optional[str] = None
error: Optional[str] = None
# Populated when the AST validator flagged risky constructs and the
# caller didn't set force=True. When present, `backend_result` is null
# because execution was deferred pending user consent.
warnings: Optional[list[str]] = None
code_preview: Optional[str] = None
class WorkspaceSeedRequest(BaseModel):
+38 -12
View File
@@ -16,7 +16,7 @@ from backend.apps.outputs.models import (
Output, OutputCreate, OutputUpdate, OutputExecute, OutputExecuteResult,
VibeCodeRequest, WorkspaceSeedRequest,
)
from backend.apps.outputs.executor import execute_backend_code
from backend.apps.outputs.executor import execute_backend_code, get_code_warnings
from backend.apps.outputs.view_builder_templates import (
VIEW_TEMPLATE_FILES,
load_app_builder_skill,
@@ -586,8 +586,14 @@ async def write_workspace_file(workspace_id: str, filepath: str, body: dict):
folder = os.path.join(WORKSPACE_DIR, workspace_id)
if not os.path.isdir(folder):
raise HTTPException(status_code=404, detail="Workspace not found")
folder_norm = os.path.normpath(folder)
full_path = os.path.normpath(os.path.join(folder, filepath))
if not full_path.startswith(os.path.normpath(folder)):
# `startswith(folder_norm + os.sep)` (not just folder_norm) so a workspace
# `abc-123` can't be tricked into writing into a sibling `abc-1234-evil` —
# prefix-string collision rather than path-component containment. Today's
# UUID-format ids make the collision unlikely in practice, but the check
# is one character and immunizes future id schemes.
if full_path != folder_norm and not full_path.startswith(folder_norm + os.sep):
raise HTTPException(status_code=403, detail="Path traversal not allowed")
os.makedirs(os.path.dirname(full_path), exist_ok=True)
with open(full_path, "w") as f:
@@ -601,8 +607,9 @@ async def delete_workspace_file(workspace_id: str, filepath: str):
folder = os.path.join(WORKSPACE_DIR, workspace_id)
if not os.path.isdir(folder):
raise HTTPException(status_code=404, detail="Workspace not found")
folder_norm = os.path.normpath(folder)
full_path = os.path.normpath(os.path.join(folder, filepath))
if not full_path.startswith(os.path.normpath(folder)):
if full_path != folder_norm and not full_path.startswith(folder_norm + os.sep):
raise HTTPException(status_code=403, detail="Path traversal not allowed")
if os.path.isfile(full_path):
os.remove(full_path)
@@ -782,16 +789,33 @@ async def execute_output(body: OutputExecute):
stdout_text = None
stderr_text = None
error = None
warnings_out: Optional[list[str]] = None
code_preview: Optional[str] = None
if output.backend_code:
try:
exec_result = await execute_backend_code(
output.backend_code, body.input_data
)
backend_result = exec_result.result
stdout_text = exec_result.stdout
stderr_text = exec_result.stderr
except Exception as e:
error = str(e)
# HITL gate: collect warnings up front. If the caller hasn't opted
# in via force=True AND the code touches anything outside the safe
# allowlist, return the warnings + the code itself so the UI can
# show a preview dialog. No subprocess is spawned on this path —
# zero-cost when warnings exist, identical-to-before when they
# don't.
if not body.force:
warnings_out = get_code_warnings(output.backend_code)
if warnings_out:
code_preview = output.backend_code
if not warnings_out:
try:
# We've either already vetted (no warnings above) or the
# user explicitly opted in with force=True. Pass
# skip_validation=True so we don't pay for a redundant
# AST walk inside execute_backend_code.
exec_result = await execute_backend_code(
output.backend_code, body.input_data, skip_validation=True
)
backend_result = exec_result.result
stdout_text = exec_result.stdout
stderr_text = exec_result.stderr
except Exception as e:
error = str(e)
return OutputExecuteResult(
output_id=output.id,
@@ -802,6 +826,8 @@ async def execute_output(body: OutputExecute):
stdout=stdout_text,
stderr=stderr_text,
error=error,
warnings=warnings_out if warnings_out else None,
code_preview=code_preview,
).model_dump()
+4
View File
@@ -2,6 +2,10 @@
Electron 40.x (CastLabs DRM build) desktop shell + auto-updater via GitHub Releases. Entry: `main.js`. Version is in `package.json`. See root `CLAUDE.md` for repo-wide constraints.
## Coding precedences
Full precedences live in root [CLAUDE.md](../.claude/CLAUDE.md). Always: **understand the end goal before coding** (what does the user actually need?); **reuse before you write** (grep existing IPC handlers / helpers in `main.js` — most needs already have one); ~300 LOC/file ceiling; downward-tree imports; no comments except WHY-non-obvious; test the packaged build path after meaningful changes (not just dev); weigh speed (startup time), efficiency (memory), robustness (auto-updater, OAuth windows), UX, security (signed binaries, no plaintext secrets) on every change.
## Build / release
- Local build: `npm run build` → produces `build-staging/` containing the frontend dist, backend bundle, standalone Python 3.13, and the 9router binary. Build artifacts are ephemeral; not git-tracked.
+4
View File
@@ -2,6 +2,10 @@
React 18 + TypeScript + webpack 5 + Redux. Entry: `src/app/Main.tsx`. Dev server on `:3000` proxies REST and WebSocket to backend on `:8324`. See root `CLAUDE.md` for repo-wide constraints.
## Coding precedences
Full precedences live in root [CLAUDE.md](../.claude/CLAUDE.md). Always: **understand the end goal before coding** (what does the user actually need?); **reuse before you write** (grep existing components / hooks / Redux slices — most needs already have one); ~300 LOC/file ceiling; downward-tree imports (`shared/` → `app/components/` → `pages/`); no comments except WHY-non-obvious; manually exercise the UI after meaningful changes; weigh speed (no double renders), efficiency, robustness, UX (loading/error/animation states), security on every change.
## Run
- Dev (full stack): `bash run.sh`.
+69 -11
View File
@@ -7,6 +7,7 @@ import Button from '@mui/material/Button';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import CircularProgress from '@mui/material/CircularProgress';
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
import { Output, executeOutput, OutputExecuteResult, getFrontendCode, getBackendCode, buildServeUrl, SERVE_BASE } from '@/shared/state/outputsSlice';
import { useAppDispatch } from '@/shared/hooks';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
@@ -27,11 +28,14 @@ const ViewRunDialog: React.FC<Props> = ({ output, onClose }) => {
const [result, setResult] = useState<OutputExecuteResult | null>(null);
const [running, setRunning] = useState(false);
const handleRun = async () => {
const warnings = result?.warnings && result.warnings.length > 0 ? result.warnings : null;
const codePreview = result?.code_preview || null;
const runWith = async (force: boolean) => {
setRunning(true);
try {
const res = await dispatch(
executeOutput({ output_id: output.id, input_data: inputData })
executeOutput({ output_id: output.id, input_data: inputData, force })
).unwrap();
setResult(res);
} finally {
@@ -39,6 +43,9 @@ const ViewRunDialog: React.FC<Props> = ({ output, onClose }) => {
}
};
const handleRun = () => runWith(false);
const handleRunAnyway = () => runWith(true);
return (
<Dialog open onClose={onClose} maxWidth="lg" fullWidth>
<DialogTitle sx={{ fontWeight: 600, color: c.text.primary }}>
@@ -107,7 +114,47 @@ const ViewRunDialog: React.FC<Props> = ({ output, onClose }) => {
<CircularProgress size={28} />
</Box>
)}
{result ? (
{warnings && codePreview ? (
<Box sx={{ p: 2, overflow: 'auto', height: '100%', display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<WarningAmberIcon sx={{ color: c.status.warning, fontSize: 22 }} />
<Typography sx={{ fontSize: '0.95rem', fontWeight: 600, color: c.text.primary }}>
Review before running
</Typography>
</Box>
<Typography sx={{ fontSize: '0.8rem', color: c.text.secondary }}>
This Output's backend code does things outside the safe
data-shaping allowlist. Read it and decide whether to run.
</Typography>
<Box
component="ul"
sx={{ m: 0, pl: 2.5, color: c.text.secondary, fontSize: '0.78rem', lineHeight: 1.55 }}
>
{warnings.map((w, i) => (
<li key={i}>{w}</li>
))}
</Box>
<Box
sx={{
flex: 1,
minHeight: 120,
mt: 0.5,
p: 1.25,
borderRadius: 1,
border: `1px solid ${c.border.subtle}`,
bgcolor: c.bg.secondary,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
fontSize: '0.74rem',
lineHeight: 1.5,
color: c.text.primary,
whiteSpace: 'pre',
overflow: 'auto',
}}
>
{codePreview}
</Box>
</Box>
) : result ? (
<ViewPreview
serveUrl={`${SERVE_BASE}/${output.id}/serve/index.html`}
frontendCode={result.frontend_code}
@@ -127,14 +174,25 @@ const ViewRunDialog: React.FC<Props> = ({ output, onClose }) => {
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={onClose} sx={{ color: c.text.muted }}>Close</Button>
<Button
variant="contained"
onClick={handleRun}
disabled={running}
sx={{ bgcolor: c.accent.primary, '&:hover': { bgcolor: c.accent.hover } }}
>
{running ? 'Running...' : getBackendCode(output) ? 'Execute & Preview' : 'Preview'}
</Button>
{warnings ? (
<Button
variant="contained"
onClick={handleRunAnyway}
disabled={running}
sx={{ bgcolor: c.status.warning, '&:hover': { bgcolor: c.status.warning } }}
>
{running ? 'Running...' : 'Run anyway'}
</Button>
) : (
<Button
variant="contained"
onClick={handleRun}
disabled={running}
sx={{ bgcolor: c.accent.primary, '&:hover': { bgcolor: c.accent.hover } }}
>
{running ? 'Running...' : getBackendCode(output) ? 'Execute & Preview' : 'Preview'}
</Button>
)}
</DialogActions>
</Dialog>
);
+9 -1
View File
@@ -60,6 +60,12 @@ export interface OutputExecuteResult {
stdout: string | null;
stderr: string | null;
error: string | null;
// Present when the backend AST validator flagged risky imports/calls and
// the caller didn't pass force=true. UI shows these alongside `code_preview`
// in a "review and Run Anyway" dialog; resubmitting with force:true bypasses
// the gate. Absent (undefined) on the happy path.
warnings?: string[] | null;
code_preview?: string | null;
}
interface OutputsState {
@@ -115,7 +121,9 @@ export const deleteOutput = createAsyncThunk('outputs/delete', async (id: string
export const executeOutput = createAsyncThunk(
'outputs/execute',
async (body: { output_id: string; input_data: Record<string, any> }) => {
// `force` opts past the AST warnings gate — only set after the user has
// seen the code preview in the run dialog and clicked Run Anyway.
async (body: { output_id: string; input_data: Record<string, any>; force?: boolean }) => {
const res = await fetch(`${OUTPUTS_API}/execute`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },