Compare commits

..

22 Commits

Author SHA1 Message Date
coder-bot cbd5e367e0 Add missing asyncpg dependency for async postgres driver
config.py's DATABASE_URL uses postgresql+asyncpg but requirements.txt
only listed the sync psycopg2-binary driver, causing ModuleNotFoundError
on app startup.
2026-08-17 20:59:17 +00:00
coder-bot 79765f09c2 WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 20:42:41 +00:00
coder-bot dda4a250fa WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 20:26:26 +00:00
coder-bot d1b91abf05 WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 20:25:43 +00:00
coder-bot 77ea745c75 WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 20:25:28 +00:00
coder-bot a3ce38ad1d WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 20:25:07 +00:00
coder-bot 19b90f6f3f WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 20:22:35 +00:00
coder-bot 923c89ecc9 WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 20:18:32 +00:00
coder-bot c88f15a052 WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 19:58:57 +00:00
coder-bot 72a994c5d1 WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 19:20:09 +00:00
coder-bot 276e63753f Recovered orphaned WIP before switching to a new task 2026-08-17 19:14:25 +00:00
coder-bot 88095b82d9 Recovered orphaned WIP before switching to a new task 2026-08-17 19:14:25 +00:00
coder-bot d11eb7d732 WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 19:14:25 +00:00
coder-bot 7fd43cc158 WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 19:14:25 +00:00
architect-bot ae37db6fc1 WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 19:14:25 +00:00
agent-bot a7a596aa47 Recovered orphaned WIP before switching to a new task 2026-08-17 19:14:25 +00:00
agent-bot aa602e78b7 Recovered orphaned WIP before switching to a new task 2026-08-17 19:14:25 +00:00
agent-bot 38c4967169 WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 19:14:25 +00:00
agent-bot 9909d3a612 WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 19:14:25 +00:00
agent-bot 4cb64e046a WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 19:14:25 +00:00
agent-bot 84b2c656f6 WIP checkpoint: Scaffold FastAPI backend project structure 2026-08-17 19:14:25 +00:00
agent-bot fdbaf2d8c1 chore(backend): add env config template
Scaffolding only — FastAPI app, SQLAlchemy models, Alembic migrations,
and docker-compose from the original task description are still not
implemented. Committing what exists so it isn't lost; task ec0074121ca3
reset to pending to finish the rest.
2026-08-17 19:14:25 +00:00
41 changed files with 1231 additions and 1128 deletions
+20
View File
@@ -0,0 +1,20 @@
# Database (async driver)
DATABASE_URL=postgresql+asyncpg://agp_user:agp_password@localhost:5432/agp_db
# Redis
REDIS_URL=redis://localhost:6379/0
# JWT
SECRET_KEY=change-me-to-a-secure-random-string
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
# App
APP_NAME=AGP Backend
APP_VERSION=0.1.0
DEBUG=true
CORS_ORIGINS=http://localhost:8080,http://localhost:3000
# File uploads
UPLOAD_DIR=uploads
MAX_UPLOAD_SIZE_MB=100
+4
View File
@@ -0,0 +1,4 @@
.env
__pycache__/
*.pyc
.venv/
View File
+42
View File
@@ -0,0 +1,42 @@
# Alembic configuration file.
# Run: alembic upgrade head (to apply migrations)
# Run: alembic revision --autogenerate -m "description" (to generate migration)
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url = postgresql+asyncpg://agp_user:agp_password@localhost:5432/agp_db
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+65
View File
@@ -0,0 +1,65 @@
"""Alembic environment configuration."""
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy.ext.asyncio import create_async_engine
from app.config import settings
from app.database import Base
from app.models import user, session, world, asset # noqa: F401
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode."""
url = settings.DATABASE_URL
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection):
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations():
"""Run migrations in 'online' mode with async engine."""
connectable = create_async_engine(settings.DATABASE_URL, echo=False)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+27
View File
@@ -0,0 +1,27 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade database schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade database schema."""
${downgrades if downgrades else "pass"}
+126
View File
@@ -0,0 +1,126 @@
"""initial schema
Revision ID: 0001_initial
Revises:
Create Date: 2024-01-01 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0001_initial"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade database schema."""
# Create ENUM types
world_visibility = postgresql.ENUM(
"public", "private", "friends_only", name="world_visibility"
)
world_visibility.create(op.get_bind(), checkfirst=True)
asset_type = postgresql.ENUM(
"image", "audio", "video", "document", "model_3d", "other", name="asset_type"
)
asset_type.create(op.get_bind(), checkfirst=True)
# Create users table
op.create_table(
"users",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("username", sa.String(length=50), nullable=False),
sa.Column("email", sa.String(length=120), nullable=False),
sa.Column("hashed_password", sa.String(length=255), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
op.create_index(op.f("ix_users_id"), "users", ["id"], unique=False)
op.create_index(op.f("ix_users_username"), "users", ["username"], unique=True)
# Create sessions table
op.create_table(
"sessions",
sa.Column("id", sa.String(length=36), primary_key=True),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("token", sa.String(), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
)
op.create_index(op.f("ix_sessions_token"), "sessions", ["token"], unique=True)
# Create worlds table
op.create_table(
"worlds",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("owner_id", sa.Integer(), nullable=False),
sa.Column(
"visibility",
postgresql.ENUM(
"public", "private", "friends_only", name="world_visibility"
),
nullable=True,
),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["owner_id"], ["users.id"]),
)
op.create_index(op.f("ix_worlds_id"), "worlds", ["id"], unique=False)
# Create assets table
op.create_table(
"assets",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column(
"asset_type",
postgresql.ENUM(
"image", "audio", "video", "document", "model_3d", "other",
name="asset_type",
),
nullable=False,
),
sa.Column("file_path", sa.String(length=512), nullable=False),
sa.Column("file_size", sa.Integer(), nullable=True),
sa.Column("mime_type", sa.String(length=128), nullable=True),
sa.Column("metadata_json", sa.Text(), nullable=True),
sa.Column("world_id", sa.Integer(), nullable=True),
sa.Column("owner_id", sa.Integer(), nullable=False),
sa.Column("is_public", sa.Boolean(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.ForeignKeyConstraint(["owner_id"], ["users.id"]),
sa.ForeignKeyConstraint(["world_id"], ["worlds.id"]),
)
op.create_index(op.f("ix_assets_id"), "assets", ["id"], unique=False)
def downgrade() -> None:
"""Downgrade database schema."""
op.drop_index(op.f("ix_assets_id"), table_name="assets")
op.drop_table("assets")
op.drop_index(op.f("ix_worlds_id"), table_name="worlds")
op.drop_table("worlds")
op.drop_index(op.f("ix_sessions_token"), table_name="sessions")
op.drop_table("sessions")
op.drop_index(op.f("ix_users_username"), table_name="users")
op.drop_index(op.f("ix_users_id"), table_name="users")
op.drop_index(op.f("ix_users_email"), table_name="users")
op.drop_table("users")
# Drop ENUM types
postgresql.ENUM(name="asset_type").drop(op.get_bind(), checkfirst=True)
postgresql.ENUM(name="world_visibility").drop(op.get_bind(), checkfirst=True)
View File
+50
View File
@@ -0,0 +1,50 @@
"""Application configuration via pydantic-settings."""
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
# App
APP_NAME: str = "AGP Backend"
APP_VERSION: str = "0.1.0"
DEBUG: bool = True
# Database (async)
DATABASE_URL: str = "postgresql+asyncpg://agp_user:agp_password@localhost:5432/agp_db"
# Redis
REDIS_URL: str = "redis://localhost:6379/0"
# JWT / Auth
SECRET_KEY: str = "change-me-to-a-secure-random-string"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
# CORS
CORS_ORIGINS: str = "http://localhost:8080,http://localhost:3000"
# File uploads
UPLOAD_DIR: str = "uploads"
MAX_UPLOAD_SIZE_MB: int = 100
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=True,
)
@property
def cors_origins_list(self) -> list[str]:
return [origin.strip() for origin in self.CORS_ORIGINS.split(",")]
@lru_cache()
def get_settings() -> Settings:
"""Return cached settings instance."""
return Settings()
# Convenience singleton
settings = get_settings()
+38
View File
@@ -0,0 +1,38 @@
"""Async database engine and session factory."""
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DEBUG,
pool_size=10,
max_overflow=20,
)
async_session_factory = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
class Base(DeclarativeBase):
pass
async def get_db() -> AsyncSession:
"""Dependency that yields an async database session."""
async with async_session_factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
+50
View File
@@ -0,0 +1,50 @@
"""Shared FastAPI dependencies, including JWT-based auth."""
from typing import Annotated
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.models.user import User
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
async def get_current_user(
token: Annotated[str, Depends(oauth2_scheme)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> User:
"""Resolve the JWT bearer token to a User, or raise 401."""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
)
user_id: str | None = payload.get("sub")
if user_id is None:
raise credentials_exception
except JWTError:
raise credentials_exception
result = await db.execute(select(User).where(User.id == int(user_id)))
user = result.scalar_one_or_none()
if user is None:
raise credentials_exception
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is inactive",
)
return user
CurrentUser = Annotated[User, Depends(get_current_user)]
+46
View File
@@ -0,0 +1,46 @@
"""FastAPI application entry point."""
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.routers import auth, users, worlds, assets
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan: startup and shutdown events."""
# Startup: initialize database, connect to Redis, etc.
yield
# Shutdown: close connections, cleanup
app = FastAPI(
title=settings.APP_NAME,
version=settings.APP_VERSION,
description="Agentic Game Platform - Backend API",
lifespan=lifespan,
)
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(users.router, prefix="/api/users", tags=["users"])
app.include_router(worlds.router, prefix="/api/worlds", tags=["worlds"])
app.include_router(assets.router, prefix="/api/assets", tags=["assets"])
@app.get("/api/health")
async def health_check():
"""Simple health check endpoint."""
return {"status": "ok", "app": settings.APP_NAME}
+6
View File
@@ -0,0 +1,6 @@
from app.models.user import User
from app.models.session import Session
from app.models.world import World
from app.models.asset import Asset
__all__ = ["User", "Session", "World", "Asset"]
+40
View File
@@ -0,0 +1,40 @@
from sqlalchemy import Column, Integer, String, ForeignKey, Text, DateTime, Boolean
from sqlalchemy.dialects.postgresql import ENUM
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import enum
from app.database import Base
class AssetType(str, enum.Enum):
IMAGE = "image"
AUDIO = "audio"
VIDEO = "video"
DOCUMENT = "document"
MODEL_3D = "model_3d"
OTHER = "other"
class Asset(Base):
__tablename__ = "assets"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
asset_type = Column(
ENUM(AssetType, name="asset_type", create_type=False),
nullable=False,
)
file_path = Column(String(512), nullable=False)
file_size = Column(Integer, nullable=True)
mime_type = Column(String(128), nullable=True)
metadata_json = Column(Text, nullable=True)
world_id = Column(Integer, ForeignKey("worlds.id"), nullable=True)
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
is_public = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
owner = relationship("User", back_populates="assets")
world = relationship("World", back_populates="assets")
+20
View File
@@ -0,0 +1,20 @@
import uuid
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
class Session(Base):
__tablename__ = "sessions"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
token = Column(String, unique=True, index=True, nullable=False)
expires_at = Column(DateTime(timezone=True), nullable=False)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
user = relationship("User", back_populates="sessions")
+21
View File
@@ -0,0 +1,21 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String(50), unique=True, index=True, nullable=False)
email = Column(String(120), unique=True, index=True, nullable=False)
hashed_password = Column(String(255), nullable=False)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan")
worlds = relationship("World", back_populates="owner")
assets = relationship("Asset", back_populates="owner")
+31
View File
@@ -0,0 +1,31 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey
from sqlalchemy.dialects.postgresql import ENUM
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import enum
from app.database import Base
class WorldVisibility(enum.Enum):
PUBLIC = "public"
PRIVATE = "private"
FRIENDS_ONLY = "friends_only"
class World(Base):
__tablename__ = "worlds"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
visibility = Column(
ENUM(WorldVisibility, name="world_visibility", create_type=False),
default=WorldVisibility.PUBLIC,
)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
owner = relationship("User", back_populates="worlds")
assets = relationship("Asset", back_populates="world", cascade="all, delete-orphan")
View File
+179
View File
@@ -0,0 +1,179 @@
"""Asset management router."""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import CurrentUser
from app.models.asset import Asset, AssetType
from app.models.world import World
from pydantic import BaseModel, Field
router = APIRouter()
# --- Pydantic schemas ---
class AssetCreate(BaseModel):
"""Schema for creating an asset reference."""
name: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = None
asset_type: AssetType
world_id: Optional[int] = None
class AssetResponse(BaseModel):
"""Schema for asset responses."""
id: int
name: str
description: Optional[str]
asset_type: AssetType
file_path: str
file_size: Optional[int]
mime_type: Optional[str]
world_id: Optional[int]
owner_id: int
is_public: bool
created_at: str
updated_at: str
class Config:
from_attributes = True
class AssetUpdate(BaseModel):
"""Schema for updating an asset."""
name: Optional[str] = None
description: Optional[str] = None
is_public: Optional[bool] = None
# --- Endpoints ---
@router.post("/", response_model=AssetResponse, status_code=201)
async def create_asset(
payload: AssetCreate,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Create a new asset reference."""
# Verify world ownership if world_id is provided
if payload.world_id is not None:
world = await db.get(World, payload.world_id)
if world is None:
raise HTTPException(status_code=404, detail="World not found")
if world.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Not owner of this world")
asset = Asset(
name=payload.name,
description=payload.description,
asset_type=payload.asset_type,
file_path="", # Will be set after upload
owner_id=current_user.id,
world_id=payload.world_id,
is_public=False,
)
db.add(asset)
await db.commit()
await db.refresh(asset)
return asset
@router.get("/", response_model=list[AssetResponse])
async def list_assets(
current_user: CurrentUser,
world_id: Optional[int] = Query(None, description="Filter by world"),
asset_type: Optional[AssetType] = Query(None),
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
):
"""List assets with optional filters."""
stmt = select(Asset).where(Asset.owner_id == current_user.id)
if world_id is not None:
stmt = stmt.where(Asset.world_id == world_id)
if asset_type is not None:
stmt = stmt.where(Asset.asset_type == asset_type)
stmt = stmt.order_by(Asset.created_at.desc()).limit(limit).offset(offset)
result = await db.execute(stmt)
assets = result.scalars().all()
return assets
@router.get("/{asset_id}", response_model=AssetResponse)
async def get_asset(
asset_id: int,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Get a single asset by ID."""
asset = await db.get(Asset, asset_id)
if asset is None or asset.owner_id != current_user.id:
raise HTTPException(status_code=404, detail="Asset not found")
return asset
@router.patch("/{asset_id}", response_model=AssetResponse)
async def update_asset(
asset_id: int,
payload: AssetUpdate,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Update an asset."""
asset = await db.get(Asset, asset_id)
if asset is None or asset.owner_id != current_user.id:
raise HTTPException(status_code=404, detail="Asset not found")
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(asset, field, value)
await db.commit()
await db.refresh(asset)
return asset
@router.delete("/{asset_id}", status_code=204)
async def delete_asset(
asset_id: int,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Delete an asset."""
asset = await db.get(Asset, asset_id)
if asset is None or asset.owner_id != current_user.id:
raise HTTPException(status_code=404, detail="Asset not found")
await db.delete(asset)
await db.commit()
@router.post("/{asset_id}/upload")
async def upload_asset_file(
asset_id: int,
file: UploadFile = File(...),
current_user: CurrentUser = None,
db: AsyncSession = Depends(get_db),
):
"""Upload a file for an existing asset."""
asset = await db.get(Asset, asset_id)
if asset is None or asset.owner_id != current_user.id:
raise HTTPException(status_code=404, detail="Asset not found")
# TODO: Implement actual file upload logic (S3, local filesystem, etc.)
file_path = f"uploads/{asset_id}_{file.filename}"
asset.file_path = file_path
asset.mime_type = file.content_type
asset.file_size = 0 # Will be updated after actual write
await db.commit()
await db.refresh(asset)
return {"message": "File upload endpoint", "asset_id": asset_id, "file_path": file_path}
+131
View File
@@ -0,0 +1,131 @@
"""Authentication router - login, register, token refresh."""
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, EmailStr
from jose import jwt
from passlib.context import CryptContext
from app.config import settings
from app.database import get_db
from app.dependencies import CurrentUser
from app.models.user import User
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
router = APIRouter()
# --- Pydantic schemas ---
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
class RegisterRequest(BaseModel):
username: str
email: EmailStr
password: str
class LoginRequest(BaseModel):
username: str
password: str
class UserResponse(BaseModel):
id: int
username: str
email: str
is_active: bool
created_at: datetime | None = None
model_config = {"from_attributes": True}
# --- Helpers ---
def _create_access_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a plain password against a hash."""
return pwd_context.verify(plain_password, hashed_password)
def hash_password(password: str) -> str:
"""Hash a password."""
return pwd_context.hash(password)
# --- Endpoints ---
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
"""Register a new user and return an access token."""
# Check if username already exists
result = await db.execute(select(User).where(User.username == req.username))
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Username already registered",
)
# Check if email already exists
result = await db.execute(select(User).where(User.email == req.email))
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Email already registered",
)
user = User(
username=req.username,
email=req.email,
hashed_password=hash_password(req.password),
)
db.add(user)
await db.commit()
await db.refresh(user)
access_token = _create_access_token({"sub": str(user.id)})
return TokenResponse(access_token=access_token)
@router.post("/login", response_model=TokenResponse)
async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
"""Authenticate user and return an access token."""
result = await db.execute(select(User).where(User.username == req.username))
user = result.scalar_one_or_none()
if not user or not verify_password(req.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is inactive",
)
access_token = _create_access_token({"sub": str(user.id)})
return TokenResponse(access_token=access_token)
@router.get("/me", response_model=UserResponse)
async def get_current_user(current_user: CurrentUser):
"""Get current user profile from the JWT token."""
return current_user
+130
View File
@@ -0,0 +1,130 @@
"""User management router."""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, EmailStr
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.user import User
router = APIRouter()
# ── Pydantic schemas ──────────────────────────────────────────────────────
class UserCreate(BaseModel):
username: str
email: EmailStr
password: str
class UserResponse(BaseModel):
id: int
username: str
email: str
is_active: bool
model_config = {"from_attributes": True}
class UserUpdate(BaseModel):
email: EmailStr | None = None
is_active: bool | None = None
# ── Endpoints ─────────────────────────────────────────────────────────────
@router.post(
"/",
response_model=UserResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_user(
user_in: UserCreate,
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Register a new user."""
# Check uniqueness of username
existing = await db.execute(select(User).where(User.username == user_in.username))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Username already taken")
# Check uniqueness of email
existing = await db.execute(select(User).where(User.email == user_in.email))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Email already registered")
from app.routers.auth import hash_password
hashed = hash_password(user_in.password)
user = User(
username=user_in.username,
email=user_in.email,
hashed_password=hashed,
)
db.add(user)
await db.commit()
await db.refresh(user)
return user
@router.get("/me", response_model=UserResponse)
async def get_current_user(
db: Annotated[AsyncSession, Depends(get_db)],
# current_user would come from JWT dependency in real usage
):
"""Get the currently authenticated user (placeholder)."""
raise HTTPException(status_code=501, detail="Not implemented")
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(
user_id: int,
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Get a user by ID."""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
@router.patch("/{user_id}", response_model=UserResponse)
async def update_user(
user_id: int,
user_update: UserUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Update a user."""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user_update.email is not None:
user.email = user_update.email
if user_update.is_active is not None:
user.is_active = user_update.is_active
await db.commit()
await db.refresh(user)
return user
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
user_id: int,
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Delete a user."""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
await db.delete(user)
await db.commit()
+154
View File
@@ -0,0 +1,154 @@
"""Worlds router - CRUD for game worlds."""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.dependencies import CurrentUser
from app.models.world import World, WorldVisibility
router = APIRouter()
DbDep = Annotated[AsyncSession, Depends(get_db)]
@router.get("/")
async def list_worlds(
db: DbDep,
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
visibility: WorldVisibility | None = None,
):
"""List worlds with optional visibility filter."""
query = select(World).options(selectinload(World.owner))
if visibility:
query = query.where(World.visibility == visibility)
query = query.offset(skip).limit(limit).order_by(World.created_at.desc())
result = await db.execute(query)
worlds = result.scalars().all()
return [
{
"id": w.id,
"name": w.name,
"description": w.description,
"visibility": w.visibility.value if w.visibility else None,
"owner_id": w.owner_id,
"created_at": w.created_at.isoformat() if w.created_at else None,
}
for w in worlds
]
@router.get("/{world_id}")
async def get_world(
world_id: int,
db: DbDep,
):
"""Get a single world by ID."""
result = await db.execute(
select(World).where(World.id == world_id).options(selectinload(World.owner))
)
world = result.scalar_one_or_none()
if not world:
raise HTTPException(status_code=404, detail="World not found")
return {
"id": world.id,
"name": world.name,
"description": world.description,
"visibility": world.visibility.value if world.visibility else None,
"owner_id": world.owner_id,
"created_at": world.created_at.isoformat() if world.created_at else None,
}
@router.post("/")
async def create_world(
world_data: dict,
current_user: CurrentUser,
db: DbDep,
):
"""Create a new world."""
name = world_data.get("name")
if not name:
raise HTTPException(status_code=400, detail="World name is required")
world = World(
name=name,
description=world_data.get("description"),
owner_id=current_user.id,
visibility=WorldVisibility(
world_data.get("visibility", "public")
),
)
db.add(world)
await db.commit()
await db.refresh(world)
return {
"id": world.id,
"name": world.name,
"description": world.description,
"visibility": world.visibility.value,
"owner_id": world.owner_id,
"created_at": world.created_at.isoformat() if world.created_at else None,
}
@router.put("/{world_id}")
async def update_world(
world_id: int,
world_data: dict,
current_user: CurrentUser,
db: DbDep,
):
"""Update an existing world."""
result = await db.execute(select(World).where(World.id == world_id))
world = result.scalar_one_or_none()
if not world:
raise HTTPException(status_code=404, detail="World not found")
if world.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Not owner of this world")
if world_data.get("name"):
world.name = world_data["name"]
if world_data.get("description") is not None:
world.description = world_data["description"]
if world_data.get("visibility"):
world.visibility = WorldVisibility(world_data["visibility"])
await db.commit()
await db.refresh(world)
return {
"id": world.id,
"name": world.name,
"description": world.description,
"visibility": world.visibility.value if world.visibility else None,
"owner_id": world.owner_id,
"created_at": world.created_at.isoformat() if world.created_at else None,
}
@router.delete("/{world_id}")
async def delete_world(
world_id: int,
current_user: CurrentUser,
db: DbDep,
):
"""Delete a world."""
result = await db.execute(select(World).where(World.id == world_id))
world = result.scalar_one_or_none()
if not world:
raise HTTPException(status_code=404, detail="World not found")
if world.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Not owner of this world")
await db.delete(world)
await db.commit()
return {"detail": "World deleted"}
+34
View File
@@ -0,0 +1,34 @@
version: "3.9"
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: agp_user
POSTGRES_PASSWORD: agp_password
POSTGRES_DB: agp_db
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U agp_user -d agp_db"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
volumes:
postgres_data:
redis_data:
+17
View File
@@ -0,0 +1,17 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy==2.0.36
alembic==1.14.0
psycopg2-binary==2.9.10
asyncpg==0.31.0
redis==5.2.1
pydantic==2.10.3
pydantic-settings==2.7.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.20
email-validator==2.2.0
httpx==0.28.1
pytest==8.3.4
pytest-asyncio==0.25.0
pytest-cov==6.0.0
-159
View File
@@ -1,159 +0,0 @@
# GDScript Sandboxing for User-Written Scripts
> **Status:** Proposed
> **Owner:** Platform team
> **Related:** Content moderation & age verification (scripts are one of the
> three moderated content types), avatar component system
## 1. Overview
This document evaluates how to safely run **user-written scripts** as part of
UGC (user-generated content) in **agp-game**, and recommends an approach.
**Bottom line: Godot 4 has no built-in security sandbox for GDScript, and
none of the three options named in the original task hold up under testing.**
A script running anywhere inside the Godot process — however it's loaded,
parented, or wrapped — runs with full engine privileges: arbitrary process
execution, full filesystem read/write, and raw network access. Sandboxing
has to happen *outside* the scripting language, not through an in-engine
restriction mechanism, because no such mechanism exists.
This was verified directly against Godot 4.6.2 (`godot --headless`, evidence
in `sandbox_probe/` on this branch) rather than assumed from documentation,
some of which (see §2.1) describes APIs that don't actually exist in this
version.
## 2. Options Evaluated
### 2.1 Godot's built-in "ScriptServer" restrictions — does not exist
There is no `ScriptServer` class in Godot 4.6.2:
```
ClassDB.class_exists("ScriptServer") → false
```
There's also no project setting to disable engine subsystems for scripts —
none of `application/run/disable_os`, `disable_file_access`, or
`disable_network` exist as settings, and `ProjectSettings.get_property_list()`
returns nothing matching "sandbox", "restrict", or "security". Any plan that
assumes a flag like this exists needs to be dropped; it was likely conflated
with sandboxing features from a different engine/scripting ecosystem, or
with **export template stripping** (which controls what ships in a build,
not what a script can call at runtime).
Directly probed capabilities of an ordinary GDScript running as a normal
node script, no special setup:
| Capability | Available to any script? |
|---|---|
| `OS.execute(...)` — run arbitrary processes | ✅ Yes |
| `FileAccess.open(...)` — read/write any path the OS user can reach | ✅ Yes (read `/etc/hostname`, write `/tmp/...` both succeeded) |
| `TCPServer` / `HTTPClient` / `HTTPRequest` / `PacketPeerUDP` / `WebSocketPeer` | ✅ All instantiate and work |
| `ClassDB.instantiate(...)` — construct any engine class by name | ✅ Yes |
| Loading and running a second script from source text at runtime (`GDScript.new()` + `.reload()`) | ✅ Yes |
**Verdict: rejected.** There is nothing to configure here — the restriction
surface this option assumed doesn't exist in the engine.
### 2.2 Isolated SubViewport with a limited API — doesn't isolate scripts
`SubViewport` is a **rendering and input isolation** primitive (it gives a
scene subtree its own render target and can gate input propagation) — it is
not a security or scripting boundary. A script attached to any node inside a
`SubViewport` still runs in the same process, on the same GDScript VM, with
the exact same access to `OS`, `FileAccess`, networking classes, and
`ClassDB` as a script anywhere else in the tree. Nothing about being
"inside" a SubViewport changes what API calls are reachable from a script.
**Verdict: rejected.** This solves a rendering/UI-composition problem, not
the security problem the task is asking about. Worth keeping in mind for
other purposes (e.g. isolated preview rendering of user avatars), but it
provides zero script sandboxing.
### 2.3 Custom GDScript interpreter with an allowlist — right idea, wrong layer for "custom interpreter"
Two ways to read this option, tested separately:
**(a) Write/embed a separate GDScript parser+interpreter.** Not
practical — this means re-implementing (and then indefinitely maintaining,
in lockstep with every Godot release) a full language interpreter outside
the engine. Far more engineering than the problem justifies.
**(b) Use Godot's built-in `Expression` class as a restricted subset.**
`Expression` *looks* like a natural allowlist mechanism — it only evaluates
a single expression, no control flow, no arbitrary statements. But it isn't
safe by default: it can still reach through to unsafe engine methods if you
aren't careful about what object you hand it as a base instance:
```gdscript
var expr = Expression.new()
expr.parse("get_environment('HOME')", ["OS"])
expr.execute([], OS) # → "/home/daddy" (successfully called OS.get_environment)
```
So `Expression` is a viable building block only if the host **never passes
a base instance (or arguments) that exposes anything sensitive** — i.e. the
allowlisting work still has to happen at the call site, `Expression` itself
enforces none of it.
**Static text pattern-matching (blocklisting dangerous substrings like
`"OS.execute"` before parsing) was also probed and is not a real
defense** — it's trivially defeated by string concatenation, `call()`,
`Callable`, or any other indirection, and produces false confidence.
**Verdict: partially viable, but only as a foundation, not a solution on
its own.** A hand-built, narrow, data-driven DSL (not full GDScript, not
raw `Expression`) with an explicit allowlist of safe operations and *no
ability to reference engine singletons or arbitrary objects* is the only
version of "custom interpreter" that's actually safe. This is real
engineering effort, not a config flag.
## 3. Recommendation
**Don't run untrusted GDScript in-process at all.** Given none of the three
in-engine options provide a real boundary, the two approaches actually worth
building are, in order of recommended priority:
1. **Primary: a narrow, non-Turing-complete content DSL**, not general
scripting. Most UGC scripting needs in a game like this (custom
interactions, simple triggers, item behaviors) don't need a general
programming language — they need a small set of declarative
verbs/conditions (e.g. `on_interact: give_item("torch")`,
`on_enter: play_sound("chime")`). Expressed as data (JSON/Resource, not
code) and interpreted by trusted, hand-written GDScript on the engine
side. This has *zero* attack surface by construction — there's no
interpreter to escape because there's no general execution, just a
fixed, reviewed set of verbs. This is almost certainly sufficient for
the actual UGC use cases in this game and should be scoped against real
feature requirements before assuming more is needed.
2. **If genuinely general scripting becomes a hard requirement**, isolate it
at the OS level, not the engine level: run untrusted scripts in a
**separate process** (a stripped-down Godot instance or a dedicated
interpreter) under OS sandboxing (a restricted user account, a
container, or `firejail`/`bwrap`/seccomp), communicating with the main
game over a narrow IPC/RPC boundary that only exposes intentionally-safe
operations. This is the only approach that provides a real security
boundary for arbitrary code, because it doesn't depend on the scripting
language cooperating — the OS enforces it. Substantially more
infrastructure than option 1; only worth it if option 1's DSL genuinely
can't express what's needed.
**Do not** attempt in-process GDScript sandboxing via SubViewport,
ScriptServer, or ad hoc blocklisting — all three were directly tested and
none provide a real boundary (§2).
## 4. Open Questions
- What do actual planned UGC scripts need to *do*? (Trigger conditions,
simple item/state effects, dialogue branching?) This determines whether
the DSL in §3.1 is sufficient or whether §3.2's process isolation is
needed. Needs a concrete feature list before implementation starts.
- If §3.2 is needed later, does it run per-user-session or shared/pooled?
Affects resource cost and isolation guarantees.
- Interaction with the content moderation pipeline (`content_moderation.md`):
scripts as a moderated content type still need a moderation flag path
regardless of which execution model is chosen — a DSL makes static
analysis of *what a script can possibly do* far easier than it would be
for general code, which is an additional point in its favor.
-8
View File
@@ -1,8 +0,0 @@
; Engine configuration file.
config_version=5
[application]
config/name="sandbox_probe"
run/main_scene="res://probe5.tscn"
config/features=PackedStringArray("4.6")
-1
View File
@@ -1 +0,0 @@
-76
View File
@@ -1,76 +0,0 @@
extends Node
func _ready():
print("=== SANDBOX PROBE START ===")
probe_script_loading()
probe_os_apis()
probe_file_access()
probe_network()
probe_engine_apis()
print("=== SANDBOX PROBE END ===")
get_tree().quit()
func probe_script_loading():
print("--- Script loading ---")
# Can we load a GDScript from a string at runtime?
var gd = GDScript.new()
gd.source_code = "extends RefCounted\nfunc hello():\n\treturn 42\n"
var err = gd.reload()
print("GDScript.reload() from source_code err=", err)
if err == OK:
var inst = gd.new()
print(" instantiated, hello()=", inst.call("hello"))
# Can we load a script resource from a path?
var res = load("res://test_script.gd")
print("load('res://test_script.gd') = ", res)
func probe_os_apis():
print("--- OS APIs ---")
# Can we execute shell commands?
var out = []
var exit_code = OS.execute("echo", ["hello"], out)
print("OS.execute('echo') exit=", exit_code, " out=", out)
# Environment access
print("OS.get_environment('HOME') = ", OS.get_environment("HOME"))
# Process spawning
print("OS.create_process available: ", OS.has_method("create_process"))
func probe_file_access():
print("--- File access ---")
# Can we read arbitrary files?
var f = FileAccess.open("/etc/hostname", FileAccess.READ)
print("FileAccess.open('/etc/hostname') = ", f)
if f:
print(" content: ", f.get_as_text())
f.close()
# Can we write outside the project?
var w = FileAccess.open("/tmp/godot_probe_write.txt", FileAccess.WRITE)
print("FileAccess.open('/tmp/...', WRITE) = ", w)
if w:
w.store_string("probe")
w.close()
print(" wrote to /tmp OK")
func probe_network():
print("--- Network ---")
# Can we open raw sockets?
var tcp = TCPServer.new()
var bind_err = tcp.listen(0)
print("TCPServer.listen(0) err=", bind_err)
# HTTP client
var http = HTTPClient.new()
print("HTTPClient instantiated: ", http != null)
func probe_engine_apis():
print("--- Engine APIs ---")
# Can we access the scene tree / add nodes?
var node = Node.new()
print("Node.new() = ", node)
# Can we access ProjectSettings?
print("ProjectSettings.get_setting('application/config/name') = ", ProjectSettings.get_setting("application/config/name"))
# Can we access the filesystem directory listing?
var dir = DirAccess.open("/")
print("DirAccess.open('/') = ", dir)
if dir:
var names = dir.get_files()
print(" root files count: ", names.size())
-6
View File
@@ -1,6 +0,0 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://probe.gd" id="1"]
[node name="Probe" type="Node"]
script = ExtResource("1")
-83
View File
@@ -1,83 +0,0 @@
extends Node
func _ready():
print("=== SANDBOX PROBE 2: Sandboxing Mechanisms ===")
probe_scriptserver()
probe_script_restrictions()
probe_subviewport_isolation()
probe_engine_restrictions()
probe_allowlist_approach()
print("=== SANDBOX PROBE 2 END ===")
get_tree().quit()
func probe_scriptserver():
print("--- ScriptServer ---")
# Does ScriptServer exist and what can it do?
print("ScriptServer has_method('get_language_count'): ", ScriptServer.has_method("get_language_count"))
print("ScriptServer has_method('get_language'): ", ScriptServer.has_method("get_language"))
# Can we enumerate registered script languages?
if ScriptServer.has_method("get_language_count"):
var count = ScriptServer.get_language_count()
print(" language count: ", count)
for i in range(count):
var lang = ScriptServer.get_language(i)
print(" language[", i, "] = ", lang)
func probe_script_restrictions():
print("--- Script restrictions ---")
# Can we restrict which classes/globals a script can access?
# Test: does GDScript have any built-in allowlist mechanism?
var gd = GDScript.new()
gd.source_code = "extends RefCounted\nfunc evil():\n\treturn OS.get_environment('HOME')\n"
var err = gd.reload()
print(" GDScript with OS access reload err=", err)
if err == OK:
var inst = gd.new()
print(" evil() result: ", inst.call("evil"))
# Can we block specific global classes?
print(" GDScript has method to restrict globals: ", gd.has_method("set_global_scope"))
func probe_subviewport_isolation():
print("--- SubViewport isolation ---")
# SubViewports isolate rendering, but do they isolate script execution?
var vp = SubViewport.new()
print(" SubViewport.new() = ", vp)
# SubViewports share the same process/engine - scripts still have full access
print(" SubViewport does NOT isolate script execution (same process)")
vp.free()
func probe_engine_restrictions():
print("--- Engine restrictions ---")
# Is there a way to disable OS/File/Network access globally?
print(" Engine.has_singleton('OS'): ", Engine.has_singleton("OS"))
print(" Engine.has_singleton('FileAccess'): ", Engine.has_singleton("FileAccess"))
# Check if there's a '--disable-*' flag or project setting
print(" ProjectSettings has 'application/run/disable_*': ", ProjectSettings.has_setting("application/run/disable_os"))
# Check for any sandbox-related project settings
var settings = ProjectSettings.get_property_list()
var sandbox_settings = []
for s in settings:
if "sandbox" in String(s.name).to_lower() or "restrict" in String(s.name).to_lower():
sandbox_settings.append(s.name)
print(" Sandbox-related project settings: ", sandbox_settings)
func probe_allowlist_approach():
print("--- Allowlist approach ---")
# The allowlist approach: parse GDScript source and reject disallowed patterns
# Test: can we detect dangerous API calls via string analysis?
var dangerous_patterns = [
"OS.execute", "OS.create_process", "OS.get_environment",
"FileAccess.open", "DirAccess.open", "TCPServer", "HTTPClient",
"load(", "preload(", "ClassDB", "Engine.get_main_loop",
"get_tree().quit", "ProjectSettings"
]
var test_script = """
extends Node
func _ready():
var f = FileAccess.open("/etc/passwd", FileAccess.READ)
OS.execute("rm", ["-rf", "/"])
"""
print(" Test script contains dangerous patterns:")
for p in dangerous_patterns:
if p in test_script:
print(" DETECTED: ", p)
-126
View File
@@ -1,126 +0,0 @@
extends Node
func _ready():
print("=== SANDBOX PROBE 3: Deep Sandboxing Mechanisms ===")
probe_global_scope_restriction()
probe_classdb_restriction()
probe_engine_singletons()
probe_script_validation()
probe_expression_engine()
probe_worker_threads()
probe_os_restriction_flags()
print("=== SANDBOX PROBE 3 END ===")
get_tree().quit()
func probe_global_scope_restriction():
print("--- Global scope restriction ---")
# Can we enumerate what's in the global scope?
var globals = []
for g in GDScript.get_global_class_list():
globals.append(g)
print(" GDScript.get_global_class_list() count: ", globals.size())
# Can we check if a global is available before using it?
print(" GDScript has 'get_global_class_list': ", GDScript.has_method("get_global_class_list"))
# Is there any way to restrict which globals a script sees?
print(" GDScript has 'set_global_scope': ", GDScript.has_method("set_global_scope"))
print(" GDScript has 'restrict_globals': ", GDScript.has_method("restrict_globals"))
# Check the GDScript class methods
var methods = GDScript.get_method_list()
var method_names = []
for m in methods:
method_names.append(m.name)
print(" GDScript methods: ", method_names)
func probe_classdb_restriction():
print("--- ClassDB restriction ---")
# ClassDB is the registry of all engine classes. Can we restrict it?
print(" ClassDB has 'get_class_list': ", ClassDB.has_method("get_class_list"))
print(" ClassDB has 'class_exists': ", ClassDB.has_method("class_exists"))
# Can we check if a specific class is available?
print(" ClassDB.class_exists('OS'): ", ClassDB.class_exists("OS"))
print(" ClassDB.class_exists('FileAccess'): ", ClassDB.class_exists("FileAccess"))
print(" ClassDB.class_exists('TCPServer'): ", ClassDB.class_exists("TCPServer"))
# Is there a way to hide classes from scripts?
print(" ClassDB has 'set_class_enabled': ", ClassDB.has_method("set_class_enabled"))
print(" ClassDB has 'disable_class': ", ClassDB.has_method("disable_class"))
func probe_engine_singletons():
print("--- Engine singletons ---")
# Engine singletons are the global objects like OS, Engine, etc.
# Can we enumerate them?
var singletons = Engine.get_singletons()
print(" Engine.get_singletons() count: ", singletons.size())
for s in singletons:
print(" singleton: ", s)
# Can we remove a singleton?
print(" Engine has 'remove_singleton': ", Engine.has_method("remove_singleton"))
print(" Engine has 'add_singleton': ", Engine.has_method("add_singleton"))
func probe_script_validation():
print("--- Script validation ---")
# Can we validate a script before running it?
var gd = GDScript.new()
gd.source_code = "extends RefCounted\nfunc evil():\n\treturn OS.get_environment('HOME')\n"
var err = gd.reload()
print(" GDScript.reload() err=", err)
# Can we get the list of identifiers used in a script?
print(" GDScript has 'get_script_method_list': ", gd.has_method("get_script_method_list"))
# Can we get the source code back?
print(" GDScript has 'get_source_code': ", gd.has_method("get_source_code"))
# Can we check what identifiers a script references?
print(" GDScript has 'get_script_constant_map': ", gd.has_method("get_script_constant_map"))
# Check if there's a way to get the AST
print(" GDScript has 'get_ast': ", gd.has_method("get_ast"))
print(" GDScript has 'get_parser': ", gd.has_method("get_parser"))
func probe_expression_engine():
print("--- Expression engine ---")
# Godot has an Expression class for evaluating expressions safely
var expr = Expression.new()
var err = expr.parse("1 + 2")
print(" Expression.parse('1 + 2') err=", err)
if err == OK:
var result = expr.execute()
print(" Expression.execute() = ", result)
# Can Expression access OS?
var expr2 = Expression.new()
err = expr2.parse("OS.get_environment('HOME')")
print(" Expression.parse('OS.get_environment') err=", err)
if err == OK:
var result2 = expr2.execute()
print(" Expression.execute() = ", result2)
# Expression with base_instance
var expr3 = Expression.new()
err = expr3.parse("get_environment('HOME')", ["OS"])
print(" Expression.parse with base_instance err=", err)
if err == OK:
var result3 = expr3.execute([], OS)
print(" Expression.execute with OS base = ", result3)
func probe_worker_threads():
print("--- Worker threads ---")
# Can we run scripts in worker threads?
print(" Thread has 'new': ", Thread.has_method("new"))
# WorkerThreadPool
print(" WorkerThreadPool has 'add_task': ", WorkerThreadPool.has_method("add_task"))
# Can we isolate a script in a thread?
print(" Thread available: ", Thread != null)
func probe_os_restriction_flags():
print("--- OS restriction flags ---")
# Check if there are any OS-level sandbox flags
print(" OS has 'get_environment': ", OS.has_method("get_environment"))
print(" OS has 'execute': ", OS.has_method("execute"))
print(" OS has 'create_process': ", OS.has_method("create_process"))
# Check project settings for sandbox-related options
var settings = ProjectSettings.get_property_list()
var relevant = []
for s in settings:
var name = String(s.name)
if "sandbox" in name.to_lower() or "restrict" in name.to_lower() or "security" in name.to_lower():
relevant.append(name)
print(" Sandbox/security project settings: ", relevant)
# Check if there's a way to disable specific features
print(" ProjectSettings has 'application/run/disable_os': ", ProjectSettings.has_setting("application/run/disable_os"))
print(" ProjectSettings has 'application/run/disable_file_access': ", ProjectSettings.has_setting("application/run/disable_file_access"))
print(" ProjectSettings has 'application/run/disable_network': ", ProjectSettings.has_setting("application/run/disable_network"))
-6
View File
@@ -1,6 +0,0 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://probe3.gd" id="1"]
[node name="Probe3" type="Node"]
script = ExtResource("1")
-232
View File
@@ -1,232 +0,0 @@
extends Node
func _ready():
print("=== SANDBOX PROBE 4: Godot 4.6.2 Sandboxing Mechanisms ===")
probe_script_loading()
probe_os_apis()
probe_file_access()
probe_network()
probe_classdb()
probe_expression_engine()
probe_engine_singletons()
probe_global_scope()
probe_worker_threads()
probe_project_settings()
print("=== SANDBOX PROBE 4 END ===")
get_tree().quit()
func probe_script_loading():
print("--- Script loading ---")
# Can we load a GDScript from a string at runtime?
var gd = GDScript.new()
gd.source_code = "extends RefCounted\nfunc hello():\n\treturn 42\n"
var err = gd.reload()
print("GDScript.reload() from source_code err=", err)
if err == OK:
var inst = gd.new()
print(" instantiated, hello()=", inst.call("hello"))
# Can we load a script resource from a path?
var res = load("res://test_script.gd")
print("load('res://test_script.gd') = ", res)
# Can we get the source code back from a loaded script?
if res:
print(" get_source_code() available: ", res.has_method("get_source_code"))
# Can we get the AST / parse tree?
print(" GDScript instance has 'get_ast': ", gd.has_method("get_ast"))
print(" GDScript instance has 'get_parser': ", gd.has_method("get_parser"))
# Can we get the list of methods?
print(" GDScript instance has 'get_script_method_list': ", gd.has_method("get_script_method_list"))
# Can we get the list of member variables?
print(" GDScript instance has 'get_script_property_list': ", gd.has_method("get_script_property_list"))
func probe_os_apis():
print("--- OS APIs ---")
# Can we execute shell commands?
var out = []
var exit_code = OS.execute("echo", ["hello"], out)
print("OS.execute('echo') exit=", exit_code, " out=", out)
# Environment access
print("OS.get_environment('HOME') = ", OS.get_environment("HOME"))
# Process spawning
print("OS has 'create_process': ", OS.has_method("create_process"))
# Can we get the command line?
print("OS.get_cmdline_args() = ", OS.get_cmdline_args())
# Can we get user data dir?
print("OS.get_user_data_dir() = ", OS.get_user_data_dir())
# Can we get executable path?
print("OS.get_executable_path() = ", OS.get_executable_path())
func probe_file_access():
print("--- File access ---")
# Can we read arbitrary files?
var f = FileAccess.open("/etc/hostname", FileAccess.READ)
print("FileAccess.open('/etc/hostname') = ", f)
if f:
print(" content: ", f.get_as_text())
f.close()
# Can we write outside the project?
var w = FileAccess.open("/tmp/godot_probe_write.txt", FileAccess.WRITE)
print("FileAccess.open('/tmp/...', WRITE) = ", w)
if w:
w.store_string("probe")
w.close()
print(" wrote to /tmp OK")
# Can we list directories?
var dir = DirAccess.open("/")
print("DirAccess.open('/') = ", dir)
if dir:
var names = dir.get_files()
print(" root files count: ", names.size())
# Can we access res:// and user:// ?
var res_dir = DirAccess.open("res://")
print("DirAccess.open('res://') = ", res_dir)
var user_dir = DirAccess.open("user://")
print("DirAccess.open('user://') = ", user_dir)
func probe_network():
print("--- Network ---")
# Can we open raw sockets?
var tcp = TCPServer.new()
var bind_err = tcp.listen(0)
print("TCPServer.listen(0) err=", bind_err)
# HTTP client
var http = HTTPClient.new()
print("HTTPClient instantiated: ", http != null)
# Can we make HTTP requests?
var http_req = HTTPRequest.new()
print("HTTPRequest instantiated: ", http_req != null)
# PacketPeerUDP
var udp = PacketPeerUDP.new()
print("PacketPeerUDP instantiated: ", udp != null)
# WebSocket
var ws = WebSocketPeer.new()
print("WebSocketPeer instantiated: ", ws != null)
func probe_classdb():
print("--- ClassDB ---")
# ClassDB is the registry of all engine classes
print("ClassDB.class_exists('OS'): ", ClassDB.class_exists("OS"))
print("ClassDB.class_exists('FileAccess'): ", ClassDB.class_exists("FileAccess"))
print("ClassDB.class_exists('TCPServer'): ", ClassDB.class_exists("TCPServer"))
print("ClassDB.class_exists('HTTPClient'): ", ClassDB.class_exists("HTTPClient"))
# Can we enumerate all classes?
var class_list = ClassDB.get_class_list()
print("ClassDB.get_class_list() count: ", class_list.size())
# Can we get methods of a class?
var os_methods = ClassDB.class_get_method_list("OS")
print("OS method count: ", os_methods.size())
# Can we get properties of a class?
var os_props = ClassDB.class_get_property_list("OS")
print("OS property count: ", os_props.size())
# Can we instantiate a class by name?
var inst = ClassDB.instantiate("Node")
print("ClassDB.instantiate('Node') = ", inst)
# Can we check if a class is a singleton?
print("ClassDB has 'class_get_singleton': ", ClassDB.has_method("class_get_singleton"))
# Can we get the singleton instance of OS?
var os_singleton = ClassDB.class_get_singleton("OS")
print("ClassDB.class_get_singleton('OS') = ", os_singleton)
# Can we check what methods are available on the singleton?
if os_singleton:
print(" OS singleton has 'execute': ", os_singleton.has_method("execute"))
print(" OS singleton has 'get_environment': ", os_singleton.has_method("get_environment"))
func probe_expression_engine():
print("--- Expression engine ---")
# Godot has an Expression class for evaluating expressions safely
var expr = Expression.new()
var err = expr.parse("1 + 2")
print("Expression.parse('1 + 2') err=", err)
if err == OK:
var result = expr.execute()
print(" Expression.execute() = ", result)
# Can Expression access OS?
var expr2 = Expression.new()
err = expr2.parse("OS.get_environment('HOME')")
print("Expression.parse('OS.get_environment') err=", err)
if err == OK:
var result2 = expr2.execute()
print(" Expression.execute() = ", result2)
# Expression with base_instance
var expr3 = Expression.new()
err = expr3.parse("get_environment('HOME')", ["OS"])
print("Expression.parse with base_instance err=", err)
if err == OK:
var result3 = expr3.execute([], OS)
print(" Expression.execute with OS base = ", result3)
# Can Expression call arbitrary methods?
var expr4 = Expression.new()
err = expr4.parse("FileAccess.open('/etc/hostname', 1)")
print("Expression.parse('FileAccess.open') err=", err)
if err == OK:
var result4 = expr4.execute()
print(" Expression.execute() = ", result4)
# Can Expression access the scene tree?
var expr5 = Expression.new()
err = expr5.parse("get_tree().quit()")
print("Expression.parse('get_tree().quit()') err=", err)
if err == OK:
var result5 = expr5.execute()
print(" Expression.execute() = ", result5)
func probe_engine_singletons():
print("--- Engine singletons ---")
# Engine singletons are the global objects like OS, Engine, etc.
var singletons = Engine.get_singletons()
print("Engine.get_singletons() count: ", singletons.size())
for s in singletons:
print(" singleton: ", s)
# Can we remove a singleton?
print("Engine has 'remove_singleton': ", Engine.has_method("remove_singleton"))
print("Engine has 'add_singleton': ", Engine.has_method("add_singleton"))
# Can we get the main loop?
print("Engine.get_main_loop() = ", Engine.get_main_loop())
# Can we get the physics server?
print("Engine.get_physics_server() = ", Engine.get_physics_server())
func probe_global_scope():
print("--- Global scope ---")
# Can we enumerate what's in the global scope?
var globals = []
for g in GDScript.get_global_class_list():
globals.append(g)
print("GDScript.get_global_class_list() count: ", globals.size())
# Can we check if a global is available before using it?
print("GDScript has 'get_global_class_list': ", GDScript.has_method("get_global_class_list"))
# Is there any way to restrict which globals a script sees?
print("GDScript has 'set_global_scope': ", GDScript.has_method("set_global_scope"))
print("GDScript has 'restrict_globals': ", GDScript.has_method("restrict_globals"))
# Check the GDScript class methods
var methods = GDScript.get_method_list()
var method_names = []
for m in methods:
method_names.append(m.name)
print(" GDScript methods: ", method_names)
func probe_worker_threads():
print("--- Worker threads ---")
# Can we run scripts in worker threads?
var thread = Thread.new()
print(" Thread.new() = ", thread)
# WorkerThreadPool
print(" WorkerThreadPool has 'add_task': ", WorkerThreadPool.has_method("add_task"))
# Can we isolate a script in a thread?
print(" Thread available: ", Thread != null)
func probe_project_settings():
print("--- Project settings ---")
# Check for any sandbox-related project settings
var settings = ProjectSettings.get_property_list()
var relevant = []
for s in settings:
var name = String(s.name)
if "sandbox" in name.to_lower() or "restrict" in name.to_lower() or "security" in name.to_lower():
relevant.append(name)
print(" Sandbox/security project settings: ", relevant)
# Check if there's a way to disable specific features
print(" ProjectSettings has 'application/run/disable_os': ", ProjectSettings.has_setting("application/run/disable_os"))
print(" ProjectSettings has 'application/run/disable_file_access': ", ProjectSettings.has_setting("application/run/disable_file_access"))
print(" ProjectSettings has 'application/run/disable_network': ", ProjectSettings.has_setting("application/run/disable_network"))
# Can we set project settings at runtime?
print(" ProjectSettings has 'set_setting': ", ProjectSettings.has_method("set_setting"))
print(" ProjectSettings has 'save': ", ProjectSettings.has_method("save"))
-6
View File
@@ -1,6 +0,0 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://probe4.gd" id="1"]
[node name="Probe4" type="Node"]
script = ExtResource("1")
-221
View File
@@ -1,221 +0,0 @@
extends Node
func _ready():
print("=== SANDBOX PROBE 5b: Corrected Sandboxing Mechanisms (Godot 4.6.2) ===")
probe_script_loading()
probe_os_apis()
probe_file_access()
probe_network()
probe_classdb()
probe_expression_engine()
probe_engine_singletons()
probe_worker_threads()
probe_project_settings()
probe_script_restrictions()
probe_allowlist()
print("=== SANDBOX PROBE 5b END ===")
get_tree().quit()
func probe_script_loading():
print("--- Script loading ---")
var gd = GDScript.new()
gd.source_code = "extends RefCounted\nfunc hello():\n\treturn 42\n"
var err = gd.reload()
print("GDScript.reload() from source_code err=", err)
if err == OK:
var inst = gd.new()
print(" instantiated, hello()=", inst.call("hello"))
var res = load("res://test_script.gd")
print("load('res://test_script.gd') = ", res)
if res:
print(" get_source_code() available: ", res.has_method("get_source_code"))
print(" GDScript instance has 'get_ast': ", gd.has_method("get_ast"))
print(" GDScript instance has 'get_parser': ", gd.has_method("get_parser"))
print(" GDScript instance has 'get_script_method_list': ", gd.has_method("get_script_method_list"))
print(" GDScript instance has 'get_script_property_list': ", gd.has_method("get_script_property_list"))
print(" GDScript instance has 'get_instance_base_type': ", gd.has_method("get_instance_base_type"))
# Can we get the list of static methods on GDScript class?
var gd_methods = GDScript.get_method_list()
var gd_method_names = []
for m in gd_methods:
gd_method_names.append(String(m.name))
print(" GDScript static methods: ", gd_method_names)
func probe_os_apis():
print("--- OS APIs ---")
var out = []
var exit_code = OS.execute("echo", ["hello"], out)
print("OS.execute('echo') exit=", exit_code, " out=", out)
print("OS.get_environment('HOME') = ", OS.get_environment("HOME"))
print("OS has 'create_process': ", OS.has_method("create_process"))
print("OS.get_cmdline_args() = ", OS.get_cmdline_args())
print("OS.get_user_data_dir() = ", OS.get_user_data_dir())
print("OS.get_executable_path() = ", OS.get_executable_path())
func probe_file_access():
print("--- File access ---")
var f = FileAccess.open("/etc/hostname", FileAccess.READ)
print("FileAccess.open('/etc/hostname') = ", f)
if f:
print(" content: ", f.get_as_text())
f.close()
var w = FileAccess.open("/tmp/godot_probe_write.txt", FileAccess.WRITE)
print("FileAccess.open('/tmp/...', WRITE) = ", w)
if w:
w.store_string("probe")
w.close()
print(" wrote to /tmp OK")
var dir = DirAccess.open("/")
print("DirAccess.open('/') = ", dir)
if dir:
var names = dir.get_files()
print(" root files count: ", names.size())
var res_dir = DirAccess.open("res://")
print("DirAccess.open('res://') = ", res_dir)
var user_dir = DirAccess.open("user://")
print("DirAccess.open('user://') = ", user_dir)
func probe_network():
print("--- Network ---")
var tcp = TCPServer.new()
var bind_err = tcp.listen(0)
print("TCPServer.listen(0) err=", bind_err)
var http = HTTPClient.new()
print("HTTPClient instantiated: ", http != null)
var http_req = HTTPRequest.new()
print("HTTPRequest instantiated: ", http_req != null)
var udp = PacketPeerUDP.new()
print("PacketPeerUDP instantiated: ", udp != null)
var ws = WebSocketPeer.new()
print("WebSocketPeer instantiated: ", ws != null)
func probe_classdb():
print("--- ClassDB ---")
print("ClassDB.class_exists('OS'): ", ClassDB.class_exists("OS"))
print("ClassDB.class_exists('FileAccess'): ", ClassDB.class_exists("FileAccess"))
print("ClassDB.class_exists('TCPServer'): ", ClassDB.class_exists("TCPServer"))
print("ClassDB.class_exists('HTTPClient'): ", ClassDB.class_exists("HTTPClient"))
var class_list = ClassDB.get_class_list()
print("ClassDB.get_class_list() count: ", class_list.size())
var os_methods = ClassDB.class_get_method_list("OS")
print("OS method count: ", os_methods.size())
var inst = ClassDB.instantiate("Node")
print("ClassDB.instantiate('Node') = ", inst)
# Can we enumerate ClassDB methods to see what's available?
var classdb_methods = ClassDB.get_method_list()
var classdb_method_names = []
for m in classdb_methods:
classdb_method_names.append(String(m.name))
print(" ClassDB methods: ", classdb_method_names)
func probe_expression_engine():
print("--- Expression engine ---")
var expr = Expression.new()
var err = expr.parse("1 + 2")
print("Expression.parse('1 + 2') err=", err)
if err == OK:
var result = expr.execute()
print(" Expression.execute() = ", result)
var expr2 = Expression.new()
err = expr2.parse("OS.get_environment('HOME')")
print("Expression.parse('OS.get_environment') err=", err)
if err == OK:
var result2 = expr2.execute()
print(" Expression.execute() = ", result2)
var expr3 = Expression.new()
err = expr3.parse("get_environment('HOME')", ["OS"])
print("Expression.parse with base_instance err=", err)
if err == OK:
var result3 = expr3.execute([], OS)
print(" Expression.execute with OS base = ", result3)
var expr4 = Expression.new()
err = expr4.parse("FileAccess.open('/etc/hostname', 1)")
print("Expression.parse('FileAccess.open') err=", err)
if err == OK:
var result4 = expr4.execute()
print(" Expression.execute() = ", result4)
var expr5 = Expression.new()
err = expr5.parse("get_tree().quit()")
print("Expression.parse('get_tree().quit()') err=", err)
if err == OK:
var result5 = expr5.execute()
print(" Expression.execute() = ", result5)
func probe_engine_singletons():
print("--- Engine singletons ---")
print("Engine has 'remove_singleton': ", Engine.has_method("remove_singleton"))
print("Engine has 'add_singleton': ", Engine.has_method("add_singleton"))
print("Engine.get_main_loop() = ", Engine.get_main_loop())
# Can we enumerate singletons via Engine instance methods?
var engine_methods = Engine.get_method_list()
var engine_method_names = []
for m in engine_methods:
engine_method_names.append(String(m.name))
print(" Engine instance methods: ", engine_method_names)
func probe_worker_threads():
print("--- Worker threads ---")
var thread = Thread.new()
print(" Thread.new() = ", thread)
print(" WorkerThreadPool has 'add_task': ", WorkerThreadPool.has_method("add_task"))
func probe_project_settings():
print("--- Project settings ---")
var settings = ProjectSettings.get_property_list()
var relevant = []
for s in settings:
var name = String(s.name)
if "sandbox" in name.to_lower() or "restrict" in name.to_lower() or "security" in name.to_lower():
relevant.append(name)
print(" Sandbox/security project settings: ", relevant)
print(" ProjectSettings has 'application/run/disable_os': ", ProjectSettings.has_setting("application/run/disable_os"))
print(" ProjectSettings has 'application/run/disable_file_access': ", ProjectSettings.has_setting("application/run/disable_file_access"))
print(" ProjectSettings has 'application/run/disable_network': ", ProjectSettings.has_setting("application/run/disable_network"))
print(" ProjectSettings has 'set_setting': ", ProjectSettings.has_method("set_setting"))
print(" ProjectSettings has 'save': ", ProjectSettings.has_method("save"))
func probe_script_restrictions():
print("--- Script restrictions ---")
var gd = GDScript.new()
gd.source_code = "extends RefCounted\nfunc evil():\n\treturn OS.get_environment('HOME')\n"
var err = gd.reload()
print(" GDScript with OS access reload err=", err)
if err == OK:
var inst = gd.new()
print(" evil() result: ", inst.call("evil"))
print(" GDScript has method to restrict globals: ", gd.has_method("set_global_scope"))
print(" GDScript has method 'get_global_scope': ", gd.has_method("get_global_scope"))
# Can we check what identifiers a script references via the analyzer?
print(" GDScript has 'get_script_constant_map': ", gd.has_method("get_script_constant_map"))
# Can we get the list of member variables?
print(" GDScript has 'get_script_property_list': ", gd.has_method("get_script_property_list"))
func probe_allowlist():
print("--- Allowlist / static analysis ---")
var dangerous_patterns = [
"OS.execute", "OS.create_process", "OS.get_environment",
"FileAccess.open", "DirAccess.open", "TCPServer", "HTTPClient",
"load(", "preload(", "ClassDB", "Engine.get_main_loop",
"get_tree().quit", "ProjectSettings"
]
var test_script = """extends Node
func _ready():
var f = FileAccess.open("/etc/passwd", FileAccess.READ)
OS.execute("rm", ["-rf", "/"])
"""
print(" Test script contains dangerous patterns:")
for p in dangerous_patterns:
if p in test_script:
print(" DETECTED: ", p)
# Can we use a tokenizer/lexer to parse GDScript?
# GDScriptLanguage is NOT exposed to GDScript (confirmed by parse error)
print(" GDScriptLanguage NOT exposed to GDScript (parse error confirmed)")
# Can we use the Expression engine to validate syntax?
var expr = Expression.new()
var err = expr.parse("var x = 1 + 2")
print(" Expression.parse('var x = 1 + 2') err=", err)
err = expr.parse("OS.execute('rm', ['-rf', '/'])")
print(" Expression.parse('OS.execute...') err=", err)
if err == OK:
var result = expr.execute()
print(" Expression.execute() = ", result)
-6
View File
@@ -1,6 +0,0 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://probe5.gd" id="1"]
[node name="Probe5" type="Node"]
script = ExtResource("1")
-174
View File
@@ -1,174 +0,0 @@
extends Node
func _ready():
print("=== SANDBOX PROBE 6: Corrected Evidence Gathering (Godot 4.6.2) ===")
probe_script_loading()
probe_os_apis()
probe_file_access()
probe_network()
probe_classdb()
probe_expression_engine()
probe_engine_singletons()
probe_worker_threads()
probe_project_settings()
probe_allowlist()
print("=== SANDBOX PROBE 6 END ===")
get_tree().quit()
func probe_script_loading():
print("--- Script loading ---")
var gd = GDScript.new()
gd.source_code = "extends RefCounted\nfunc hello():\n\treturn 42\n"
var err = gd.reload()
print("GDScript.reload() from source_code err=", err)
if err == OK:
var inst = gd.new()
print(" instantiated, hello()=", inst.call("hello"))
var res = load("res://test_script.gd")
print("load('res://test_script.gd') = ", res)
if res:
print(" get_source_code() available: ", res.has_method("get_source_code"))
print(" GDScript instance has 'get_ast': ", gd.has_method("get_ast"))
print(" GDScript instance has 'get_parser': ", gd.has_method("get_parser"))
print(" GDScript instance has 'get_script_method_list': ", gd.has_method("get_script_method_list"))
print(" GDScript instance has 'get_script_property_list': ", gd.has_method("get_script_property_list"))
func probe_os_apis():
print("--- OS APIs ---")
var out = []
var exit_code = OS.execute("echo", ["hello"], out)
print("OS.execute('echo') exit=", exit_code, " out=", out)
print("OS.get_environment('HOME') = ", OS.get_environment("HOME"))
print("OS has 'create_process': ", OS.has_method("create_process"))
print("OS.get_cmdline_args() = ", OS.get_cmdline_args())
print("OS.get_user_data_dir() = ", OS.get_user_data_dir())
print("OS.get_executable_path() = ", OS.get_executable_path())
func probe_file_access():
print("--- File access ---")
var f = FileAccess.open("/etc/hostname", FileAccess.READ)
print("FileAccess.open('/etc/hostname') = ", f)
if f:
print(" content: ", f.get_as_text())
f.close()
var w = FileAccess.open("/tmp/godot_probe_write.txt", FileAccess.WRITE)
print("FileAccess.open('/tmp/...', WRITE) = ", w)
if w:
w.store_string("probe")
w.close()
print(" wrote to /tmp OK")
var dir = DirAccess.open("/")
print("DirAccess.open('/') = ", dir)
if dir:
var names = dir.get_files()
print(" root files count: ", names.size())
var res_dir = DirAccess.open("res://")
print("DirAccess.open('res://') = ", res_dir)
var user_dir = DirAccess.open("user://")
print("DirAccess.open('user://') = ", user_dir)
func probe_network():
print("--- Network ---")
var tcp = TCPServer.new()
var bind_err = tcp.listen(0)
print("TCPServer.listen(0) err=", bind_err)
var http = HTTPClient.new()
print("HTTPClient instantiated: ", http != null)
var http_req = HTTPRequest.new()
print("HTTPRequest instantiated: ", http_req != null)
var udp = PacketPeerUDP.new()
print("PacketPeerUDP instantiated: ", udp != null)
var ws = WebSocketPeer.new()
print("WebSocketPeer instantiated: ", ws != null)
func probe_classdb():
print("--- ClassDB ---")
print("ClassDB.class_exists('OS'): ", ClassDB.class_exists("OS"))
print("ClassDB.class_exists('FileAccess'): ", ClassDB.class_exists("FileAccess"))
print("ClassDB.class_exists('TCPServer'): ", ClassDB.class_exists("TCPServer"))
print("ClassDB.class_exists('HTTPClient'): ", ClassDB.class_exists("HTTPClient"))
var class_list = ClassDB.get_class_list()
print("ClassDB.get_class_list() count: ", class_list.size())
var os_methods = ClassDB.class_get_method_list("OS")
print("OS method count: ", os_methods.size())
var inst = ClassDB.instantiate("Node")
print("ClassDB.instantiate('Node') = ", inst)
print("ClassDB has 'class_get_singleton': ", ClassDB.has_method("class_get_singleton"))
func probe_expression_engine():
print("--- Expression engine ---")
var expr = Expression.new()
var err = expr.parse("1 + 2")
print("Expression.parse('1 + 2') err=", err)
if err == OK:
var result = expr.execute()
print(" Expression.execute() = ", result)
var expr2 = Expression.new()
err = expr2.parse("OS.get_environment('HOME')")
print("Expression.parse('OS.get_environment') err=", err)
if err == OK:
var result2 = expr2.execute()
print(" Expression.execute() = ", result2)
var expr3 = Expression.new()
err = expr3.parse("get_environment('HOME')", ["OS"])
print("Expression.parse with base_instance err=", err)
if err == OK:
var result3 = expr3.execute([], OS)
print(" Expression.execute with OS base = ", result3)
var expr4 = Expression.new()
err = expr4.parse("FileAccess.open('/etc/hostname', 1)")
print("Expression.parse('FileAccess.open') err=", err)
if err == OK:
var result4 = expr4.execute()
print(" Expression.execute() = ", result4)
var expr5 = Expression.new()
err = expr5.parse("get_tree().quit()")
print("Expression.parse('get_tree().quit()') err=", err)
if err == OK:
var result5 = expr5.execute()
print(" Expression.execute() = ", result5)
func probe_engine_singletons():
print("--- Engine singletons ---")
print("Engine has 'remove_singleton': ", Engine.has_method("remove_singleton"))
print("Engine has 'add_singleton': ", Engine.has_method("add_singleton"))
print("Engine.get_main_loop() = ", Engine.get_main_loop())
func probe_worker_threads():
print("--- Worker threads ---")
var thread = Thread.new()
print(" Thread.new() = ", thread)
print(" WorkerThreadPool has 'add_task': ", WorkerThreadPool.has_method("add_task"))
func probe_project_settings():
print("--- Project settings ---")
var settings = ProjectSettings.get_property_list()
var relevant = []
for s in settings:
var name = String(s.name)
if "sandbox" in name.to_lower() or "restrict" in name.to_lower() or "security" in name.to_lower():
relevant.append(name)
print(" Sandbox/security project settings: ", relevant)
print(" ProjectSettings has 'application/run/disable_os': ", ProjectSettings.has_setting("application/run/disable_os"))
print(" ProjectSettings has 'application/run/disable_file_access': ", ProjectSettings.has_setting("application/run/disable_file_access"))
print(" ProjectSettings has 'application/run/disable_network': ", ProjectSettings.has_setting("application/run/disable_network"))
print(" ProjectSettings has 'set_setting': ", ProjectSettings.has_method("set_setting"))
print(" ProjectSettings has 'save': ", ProjectSettings.has_method("save"))
func probe_allowlist():
print("--- Allowlist / static analysis ---")
var dangerous_patterns = [
"OS.execute", "OS.create_process", "OS.get_environment",
"FileAccess.open", "DirAccess.open", "TCPServer", "HTTPClient",
"load(", "preload(", "ClassDB", "Engine.get_main_loop",
"get_tree().quit", "ProjectSettings"
]
var test_script = """extends Node
func _ready():
var f = FileAccess.open("/etc/passwd", FileAccess.READ)
OS.execute("rm", ["-rf", "/"])
"""
print(" Test script contains dangerous patterns:")
for p in dangerous_patterns:
if p in test_script:
print(" DETECTED: ", p)
-6
View File
@@ -1,6 +0,0 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://probe6.gd" id="1"]
[node name="Probe6" type="Node"]
script = ExtResource("1")
@@ -1,6 +0,0 @@
extends SceneTree
func _init():
print("ScriptServer class exists: ", ClassDB.class_exists("ScriptServer"))
var sv = SubViewport.new()
print("SubViewport instantiated: ", sv != null)
quit()
-8
View File
@@ -1,8 +0,0 @@
; Engine configuration file.
config_version=5
[application]
config/name="sandbox_probe"
run/main_scene="res://probe5.tscn"
config/features=PackedStringArray("4.6")
-4
View File
@@ -1,4 +0,0 @@
extends RefCounted
func hello():
return "from test_script.gd"