diff --git a/src/godotiq/tools/bridge/script_ops.py b/src/godotiq/tools/bridge/script_ops.py
new file mode 100644
index 0000000..b33beb1
--- /dev/null
+++ b/src/godotiq/tools/bridge/script_ops.py
@@ -0,0 +1,303 @@
+"""Script operations tool -- filesystem-based script reading, writing, patching, and creation."""
+
+from __future__ import annotations
+
+import os
+import re
+from pathlib import Path
+
+PROTECTED_PATTERNS = [
+    "project.godot",
+    ".godot/",
+    ".import",
+    "saves/",
+]
+
+
+def _resolve_path(path: str) -> Path:
+    """Resolve a res:// or relative path to absolute filesystem path.
+
+    Raises ValueError if the resolved path is outside the project root.
+    """
+    root = Path(os.environ.get("GODOTIQ_PROJECT_ROOT", ".")).resolve()
+
+    if path.startswith("res://"):
+        path = path[len("res://"):]
+
+    resolved = (root / path).resolve()
+
+    if not str(resolved).startswith(str(root)):
+        raise ValueError(f"Path resolves outside project root: {path}")
+
+    return resolved
+
+
+def _is_protected(path: str) -> bool:
+    """Check if path is protected (should not be modified)."""
+    normalized = path.replace("\\", "/")
+
+    if normalized == "project.godot" or normalized.endswith("/project.godot"):
+        return True
+    if ".godot/" in normalized or normalized.startswith(".godot"):
+        return True
+    if normalized.endswith(".import"):
+        return True
+    if normalized.startswith("saves/") or "/saves/" in normalized:
+        return True
+
+    return False
+
+
+def _find_line_number(text: str, search: str) -> int:
+    """Find the 1-based line number where search string starts.
+
+    Returns -1 if not found.
+    """
+    idx = text.find(search)
+    if idx == -1:
+        return -1
+    return text[:idx].count("\n") + 1
+
+
+def _validate_gdscript(content: str, path: str) -> list[dict]:
+    """Basic GDScript convention validation.
+
+    Currently checks for var declarations without type hints.
+
+    Returns:
+        List of issue dicts with severity, rule, line, and detail keys.
+    """
+    issues: list[dict] = []
+    lines = content.split("\n")
+
+    for i, line in enumerate(lines, start=1):
+        stripped = line.strip()
+
+        # Skip comments, empty lines, and @onready vars
+        if not stripped or stripped.startswith("#"):
+            continue
+        if stripped.startswith("@onready"):
+            continue
+
+        # Check for var declarations without type hints
+        if re.match(r"^var\s+\w+\s*=", stripped):
+            issues.append({
+                "severity": "warning",
+                "rule": "type_hint",
+                "line": i,
+                "detail": f"Variable declaration without type hint: {stripped}",
+            })
+
+    return issues
+
+
+async def script_ops(
+    op: str = "read",
+    path: str = "",
+    content: str = "",
+    patches: list[dict] | None = None,
+    validate_before_write: bool = True,
+    auto_fix: bool = False,
+    dry_run: bool = False,
+) -> dict:
+    """Read, write, patch, or create a script file.
+
+    Args:
+        op: Operation mode - "read", "write", "patch", or "create".
+        path: File path (res:// or relative to project root).
+        content: File content for write/create modes.
+        patches: List of {search, replace} dicts for patch mode.
+        validate_before_write: Run GDScript validation before writing.
+        auto_fix: Reserved for future auto-fix functionality.
+        dry_run: If True, validate but don't write.
+
+    Returns:
+        Dict with status and operation-specific results.
+    """
+    if not path:
+        return {"status": "ERROR", "error": "Path is required."}
+
+    try:
+        resolved = _resolve_path(path)
+    except ValueError as e:
+        return {"status": "ERROR", "error": str(e)}
+
+    rel_path = path.removeprefix("res://") if path.startswith("res://") else path
+
+    if op == "read":
+        return await _op_read(resolved, rel_path)
+    elif op == "write":
+        return await _op_write(resolved, rel_path, content, validate_before_write, dry_run)
+    elif op == "patch":
+        return await _op_patch(resolved, rel_path, patches or [], validate_before_write, dry_run)
+    elif op == "create":
+        return await _op_create(resolved, rel_path, content)
+    else:
+        return {"status": "ERROR", "error": f"Unknown operation: {op}"}
+
+
+async def _op_read(resolved: Path, rel_path: str) -> dict:
+    """Read a script file."""
+    if not resolved.exists():
+        return {"status": "ERROR", "error": f"Not found: {rel_path}"}
+
+    text = resolved.read_text(encoding="utf-8")
+    line_count = text.count("\n")
+    if text and not text.endswith("\n"):
+        line_count += 1
+
+    return {
+        "status": "OK",
+        "path": rel_path,
+        "content": text,
+        "lines": line_count,
+        "size_bytes": resolved.stat().st_size,
+    }
+
+
+async def _op_write(
+    resolved: Path, rel_path: str, content: str,
+    validate_before_write: bool, dry_run: bool,
+) -> dict:
+    """Write a script file."""
+    if _is_protected(rel_path):
+        return {"status": "BLOCKED", "error": f"Protected file: {rel_path}"}
+
+    if not content:
+        return {"status": "ERROR", "error": "Content is required for write."}
+
+    validation_issues: list[dict] = []
+    if validate_before_write:
+        validation_issues = _validate_gdscript(content, rel_path)
+
+    line_count = content.count("\n")
+    if content and not content.endswith("\n"):
+        line_count += 1
+
+    if dry_run:
+        return {
+            "status": "DRY_RUN",
+            "path": rel_path,
+            "lines": line_count,
+            "validation_issues": validation_issues,
+            "written": False,
+        }
+
+    resolved.parent.mkdir(parents=True, exist_ok=True)
+    resolved.write_text(content, encoding="utf-8")
+
+    return {
+        "status": "OK",
+        "path": rel_path,
+        "lines": line_count,
+        "validation_issues": validation_issues,
+        "written": True,
+    }
+
+
+async def _op_patch(
+    resolved: Path, rel_path: str, patches: list[dict],
+    validate_before_write: bool, dry_run: bool,
+) -> dict:
+    """Patch a script file with find-and-replace operations."""
+    if _is_protected(rel_path):
+        return {"status": "BLOCKED", "error": f"Protected file: {rel_path}"}
+
+    if not resolved.exists():
+        return {"status": "ERROR", "error": f"Not found: {rel_path}"}
+
+    if not patches:
+        return {"status": "ERROR", "error": "No patches provided.", "written": False}
+
+    text = resolved.read_text(encoding="utf-8")
+    applied: list[dict] = []
+    failed: list[dict] = []
+
+    for patch in patches:
+        search = patch.get("search", "")
+        replace = patch.get("replace", "")
+
+        if not search:
+            failed.append({"search": search, "error": "Empty search string."})
+            continue
+
+        count = text.count(search)
+        if count == 0:
+            failed.append({"search": search, "error": "Not found in file."})
+            continue
+        if count > 1:
+            failed.append({"search": search, "error": f"Ambiguous: found {count} matches."})
+            continue
+
+        line = _find_line_number(text, search)
+        text = text.replace(search, replace, 1)
+        applied.append({"search": search, "replace": replace, "line": line})
+
+    if failed and not applied:
+        return {
+            "status": "ERROR",
+            "path": rel_path,
+            "applied": applied,
+            "failed": failed,
+            "written": False,
+        }
+
+    if failed:
+        return {
+            "status": "PARTIAL",
+            "path": rel_path,
+            "applied": applied,
+            "failed": failed,
+            "written": False,
+        }
+
+    validation_issues: list[dict] = []
+    if validate_before_write:
+        validation_issues = _validate_gdscript(text, rel_path)
+
+    if dry_run:
+        return {
+            "status": "DRY_RUN",
+            "path": rel_path,
+            "applied": applied,
+            "failed": failed,
+            "validation_issues": validation_issues,
+            "written": False,
+        }
+
+    resolved.write_text(text, encoding="utf-8")
+
+    return {
+        "status": "OK",
+        "path": rel_path,
+        "applied": applied,
+        "failed": failed,
+        "validation_issues": validation_issues,
+        "written": True,
+    }
+
+
+async def _op_create(resolved: Path, rel_path: str, content: str) -> dict:
+    """Create a new script file."""
+    if _is_protected(rel_path):
+        return {"status": "BLOCKED", "error": f"Protected file: {rel_path}"}
+
+    if resolved.exists():
+        return {"status": "ERROR", "error": f"File already exists: {rel_path}"}
+
+    if not content:
+        return {"status": "ERROR", "error": "Content is required for create."}
+
+    line_count = content.count("\n")
+    if content and not content.endswith("\n"):
+        line_count += 1
+
+    resolved.parent.mkdir(parents=True, exist_ok=True)
+    resolved.write_text(content, encoding="utf-8")
+
+    return {
+        "status": "OK",
+        "path": rel_path,
+        "created": True,
+        "lines": line_count,
+    }
diff --git a/tests/test_bridge/test_script_ops.py b/tests/test_bridge/test_script_ops.py
new file mode 100644
index 0000000..44087dd
--- /dev/null
+++ b/tests/test_bridge/test_script_ops.py
@@ -0,0 +1,241 @@
+"""Tests for script_ops filesystem tool."""
+
+from __future__ import annotations
+
+import pytest
+from pathlib import Path
+
+from godotiq.tools.bridge.script_ops import script_ops, _resolve_path, _is_protected, _validate_gdscript
+
+
+class TestResolvePath:
+    """Path resolution and containment validation."""
+
+    def test_res_prefix_resolves_relative_to_project_root(self, tmp_path: Path, monkeypatch) -> None:
+        """_resolve_path with res:// prefix resolves relative to GODOTIQ_PROJECT_ROOT."""
+        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
+        result = _resolve_path("res://scripts/player.gd")
+        assert result == tmp_path / "scripts" / "player.gd"
+
+    def test_relative_path_resolves_relative_to_project_root(self, tmp_path: Path, monkeypatch) -> None:
+        """_resolve_path with relative path resolves relative to GODOTIQ_PROJECT_ROOT."""
+        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
+        result = _resolve_path("scripts/player.gd")
+        assert result == tmp_path / "scripts" / "player.gd"
+
+    def test_path_traversal_raises_error(self, tmp_path: Path, monkeypatch) -> None:
+        """_resolve_path with ../../etc/passwd raises error (containment check)."""
+        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
+        with pytest.raises(ValueError, match="outside project"):
+            _resolve_path("../../etc/passwd")
+
+    def test_res_traversal_raises_error(self, tmp_path: Path, monkeypatch) -> None:
+        """_resolve_path with res://../.. traversal raises error."""
+        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
+        with pytest.raises(ValueError, match="outside project"):
+            _resolve_path("res://../../etc/passwd")
+
+
+class TestIsProtected:
+    """Protected file pattern matching."""
+
+    def test_project_godot_is_protected(self) -> None:
+        """_is_protected returns True for project.godot."""
+        assert _is_protected("project.godot") is True
+
+    def test_godot_dir_is_protected(self) -> None:
+        """_is_protected returns True for paths inside .godot/."""
+        assert _is_protected(".godot/imported/thing.res") is True
+
+    def test_import_files_are_protected(self) -> None:
+        """_is_protected returns True for .import files."""
+        assert _is_protected("icon.png.import") is True
+
+    def test_saves_dir_is_protected(self) -> None:
+        """_is_protected returns True for paths inside saves/."""
+        assert _is_protected("saves/game.sav") is True
+
+    def test_regular_gd_file_not_protected(self) -> None:
+        """_is_protected returns False for regular .gd files."""
+        assert _is_protected("scripts/player.gd") is False
+
+
+class TestReadMode:
+    """Read operation tests."""
+
+    async def test_read_returns_content_and_metadata(self, tmp_path: Path, monkeypatch) -> None:
+        """Read mode returns file content, line count, and size."""
+        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
+        f = tmp_path / "test.gd"
+        f.write_text("extends Node\n\nfunc _ready():\n\tpass\n", encoding="utf-8")
+        result = await script_ops(op="read", path="test.gd")
+        assert result["status"] == "OK"
+        assert "extends Node" in result["content"]
+        assert result["lines"] == 4
+        assert result["size_bytes"] > 0
+
+    async def test_read_missing_file_returns_error(self, tmp_path: Path, monkeypatch) -> None:
+        """Read mode raises error for missing file."""
+        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
+        result = await script_ops(op="read", path="nonexistent.gd")
+        assert result["status"] == "ERROR"
+        assert "not found" in result["error"].lower() or "Not found" in result["error"]
+
+
+class TestWriteMode:
+    """Write operation tests."""
+
+    async def test_write_creates_file(self, tmp_path: Path, monkeypatch) -> None:
+        """Write mode creates/overwrites file with content."""
+        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
+        result = await script_ops(op="write", path="new_script.gd", content="extends Node\n", validate_before_write=False)
+        assert result["status"] == "OK"
+        assert result["written"] is True
+        assert (tmp_path / "new_script.gd").read_text() == "extends Node\n"
+
+    async def test_write_rejects_protected_files(self, tmp_path: Path, monkeypatch) -> None:
+        """Write mode rejects protected files."""
+        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
+        result = await script_ops(op="write", path="project.godot", content="test")
+        assert result["status"] == "BLOCKED"
+
+    async def test_write_with_validation(self, tmp_path: Path, monkeypatch) -> None:
+        """Write mode with validate_before_write=True runs GDScript validation."""
+        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
+        content = "extends Node\nvar speed = 10\n"
+        result = await script_ops(op="write", path="test.gd", content=content, validate_before_write=True)
+        assert result["status"] == "OK"
+        # validation_issues may or may not be empty depending on the content
+        assert "validation_issues" in result
+
+    async def test_write_dry_run_does_not_write(self, tmp_path: Path, monkeypatch) -> None:
+        """Write mode with dry_run=True validates but doesn't write."""
+        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
+        result = await script_ops(op="write", path="test.gd", content="extends Node\n", dry_run=True, validate_before_write=False)
+        assert result["status"] == "DRY_RUN"
+        assert result["written"] is False
+        assert not (tmp_path / "test.gd").exists()
+
+    async def test_write_path_traversal_blocked(self, tmp_path: Path, monkeypatch) -> None:
+        """Write mode path traversal blocked."""
+        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
+        result = await script_ops(op="write", path="../../etc/evil.gd", content="test")
+        assert result["status"] == "ERROR"
+
+
+class TestPatchMode:
+    """Patch operation tests."""
+
+    async def test_patch_single_replacement(self, tmp_path: Path, monkeypatch) -> None:
+        """Patch mode applies single find-and-replace."""
+        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
+        f = tmp_path / "test.gd"
+        f.write_text("var speed = 10\n", encoding="utf-8")
+        result = await script_ops(
+            op="patch", path="test.gd",
+            patches=[{"search": "speed = 10", "replace": "speed: float = 20.0"}],
+            validate_before_write=False,
+        )
+        assert result["status"] == "OK"
+        assert result["written"] is True
+        assert "speed: float = 20.0" in f.read_text()
+
+    async def test_patch_multiple_sequential(self, tmp_path: Path, monkeypatch) -> None:
+        """Patch mode applies multiple patches sequentially."""
+        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
+        f = tmp_path / "test.gd"
+        f.write_text("var a = 1\nvar b = 2\n", encoding="utf-8")
+        result = await script_ops(
+            op="patch", path="test.gd",
+            patches=[
+                {"search": "var a = 1", "replace": "var a: int = 1"},
+                {"search": "var b = 2", "replace": "var b: int = 2"},
+            ],
+            validate_before_write=False,
+        )
+        assert result["status"] == "OK"
+        content = f.read_text()
+        assert "var a: int = 1" in content
+        assert "var b: int = 2" in content
+
+    async def test_patch_rejects_non_unique_search(self, tmp_path: Path, monkeypatch) -> None:
+        """Patch mode rejects non-unique search string (appears 0 or 2+ times)."""
+        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
+        f = tmp_path / "test.gd"
+        f.write_text("pass\npass\n", encoding="utf-8")
+        result = await script_ops(
+            op="patch", path="test.gd",
+            patches=[{"search": "pass", "replace": "return"}],
+            validate_before_write=False,
+        )
+        assert result["status"] == "ERROR"
+        assert len(result["failed"]) > 0
+
+    async def test_patch_line_numbers_before_replacement(self, tmp_path: Path, monkeypatch) -> None:
+        """Patch mode returns line numbers calculated BEFORE replacement."""
+        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
+        f = tmp_path / "test.gd"
+        f.write_text("line1\nTARGET\nline3\n", encoding="utf-8")
+        result = await script_ops(
+            op="patch", path="test.gd",
+            patches=[{"search": "TARGET", "replace": "REPLACED"}],
+            validate_before_write=False,
+        )
+        assert result["status"] == "OK"
+        assert result["applied"][0]["line"] == 2
+
+    async def test_patch_rejects_protected_files(self, tmp_path: Path, monkeypatch) -> None:
+        """Patch mode rejects protected files."""
+        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
+        f = tmp_path / "project.godot"
+        f.write_text("config_version=5\n", encoding="utf-8")
+        result = await script_ops(
+            op="patch", path="project.godot",
+            patches=[{"search": "5", "replace": "4"}],
+        )
+        assert result["status"] == "BLOCKED"
+
+    async def test_patch_empty_patches_returns_no_changes(self, tmp_path: Path, monkeypatch) -> None:
+        """Patch mode with empty patches list returns unchanged file."""
+        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
+        f = tmp_path / "test.gd"
+        f.write_text("extends Node\n", encoding="utf-8")
+        result = await script_ops(op="patch", path="test.gd", patches=[])
+        assert result["written"] is False or result["status"] == "ERROR"
+
+
+class TestCreateMode:
+    """Create operation tests."""
+
+    async def test_create_new_file(self, tmp_path: Path, monkeypatch) -> None:
+        """Create mode creates new file."""
+        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
+        result = await script_ops(op="create", path="new.gd", content="extends Node\n")
+        assert result["status"] == "OK"
+        assert result["created"] is True
+        assert (tmp_path / "new.gd").exists()
+
+    async def test_create_fails_if_exists(self, tmp_path: Path, monkeypatch) -> None:
+        """Create mode fails if file already exists."""
+        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
+        (tmp_path / "existing.gd").write_text("old", encoding="utf-8")
+        result = await script_ops(op="create", path="existing.gd", content="new")
+        assert result["status"] == "ERROR"
+        assert "exists" in result["error"].lower()
+
+    async def test_create_rejects_protected_paths(self, tmp_path: Path, monkeypatch) -> None:
+        """Create mode rejects protected paths."""
+        monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))
+        result = await script_ops(op="create", path=".godot/cache.gd", content="test")
+        assert result["status"] == "BLOCKED"
+
+
+class TestValidation:
+    """GDScript convention validation."""
+
+    def test_validate_warns_on_missing_type_hints(self) -> None:
+        """_validate_gdscript checks for type hints (basic convention check)."""
+        content = "extends Node\nvar speed = 10\n"
+        issues = _validate_gdscript(content, "test.gd")
+        assert len(issues) > 0
+        assert any(i["rule"] == "type_hint" for i in issues)
