Skip to content

Fix 10 friction points from cyberpunk HUD stress test#21

Merged
dsarno merged 40 commits into
mainfrom
friction/tier1-fixes
Apr 16, 2026
Merged

Fix 10 friction points from cyberpunk HUD stress test#21
dsarno merged 40 commits into
mainfrom
friction/tier1-fixes

Conversation

@dsarno

@dsarno dsarno commented Apr 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes 10 of the 12 friction points documented in the cyberpunk HUD stress test, organized by priority tier.

Tier 1 — High impact, straightforward fixes

  • Fix 1: theme_create auto-creates parent directories (same pattern as script_create)
  • Fix 2: ui_build_layout supports theme_override_colors/, _constants/, _font_sizes/, _styles/ pseudo-properties
  • Fix 3: theme_set_stylebox_flat adds 12 per-side parameters (border_width_top/bottom/left/right, corner_radius per-corner, content_margin per-side)
  • Fix 4: editor_screenshot source=game — fixed SubViewport type mismatch

Tier 2 — New capabilities

  • Fix 5: Scene instancing via node_create — new scene_path parameter loads and instantiates a PackedScene
  • Fix 6: animation_delete tool + overwrite parameter on animation_create and animation_create_simple
  • Fix 7: Allow renaming scene root (removed artificial guard)

Tier 3 — Nice to have

  • Fix 8: project_stop readiness race — 150ms settle delay prevents stale readiness
  • Fix 9: signal_connect autoload support — falls back to ProjectSettings autoload entries
  • Fix 10: animation_validate tool — read-only check that all track paths resolve to actual nodes

Test plan

  • ruff check passes
  • pytest -v — 385 passed
  • test_run in live Godot — 332 passed, 0 failed
  • Each fix includes GDScript tests, Python unit tests, and integration tests

dsarno and others added 3 commits April 15, 2026 19:56
Four high-impact fixes discovered during the MCP-only HUD build exercise:

1. theme_create: auto-create parent directories before saving
   (same DirAccess.make_dir_recursive_absolute pattern as script_create)

2. ui_build_layout: support theme_override_* pseudo-properties
   (theme_override_colors/, _constants/, _font_sizes/, _styles/ prefixes
   now route to add_theme_*_override methods with proper coercion)

3. theme_set_stylebox_flat: add 12 per-side parameters
   (border_width_top/bottom/left/right, corner_radius per-corner,
   content_margin per-side — all override uniform values when provided)

4. editor_screenshot source="game": fix viewport type mismatch
   (variable was typed SubViewport but game viewport is a Viewport;
   implicit cast returned null, cascading to Python KeyError)

All fixes include GDScript tests, Python unit tests, and integration tests.
pytest: 377 passed | test_run: 312 passed, 0 failed

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

Three new capabilities discovered during the cyberpunk HUD stress test:

1. Scene instancing via node_create: new scene_path parameter loads a
   PackedScene and instantiates it (mutually exclusive with type param).
   Supports undo, sets owner recursively for proper serialization.

2. animation_delete tool + overwrite parameter: new animation_delete
   command removes a clip with full undo support. Both animation_create
   and animation_create_simple now accept overwrite=true to replace
   existing animations in one atomic undo action.

3. Allow renaming scene root: removed the artificial guard that blocked
   renaming the scene root node. Godot's editor UI allows this natively.
   Root rename doesn't affect the .tscn file path.

All fixes include GDScript tests, Python unit tests, and handler updates.
pytest: 382 passed | test_run: 327 passed, 1 pre-existing failure

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

Three "nice to have" improvements from the cyberpunk HUD stress test:

1. project_stop readiness race: add 150ms settle delay in Python handler
   after stop_project returns, giving the editor time to emit
   readiness_changed before the next tool call arrives.

2. signal_connect autoload support: when ScenePath.resolve fails, fall
   back to checking ProjectSettings autoload entries and resolving via
   Engine.get_main_loop().root. Both source and target paths now resolve
   autoload singletons. Error messages clarify "not in scene tree or
   autoloads".

3. animation_validate tool: read-only tool that checks all track paths
   in an animation resolve to actual nodes. Reports valid_count,
   broken_count, and broken_tracks with index/path/issue for each
   stale reference. Does not require writable session.

All fixes include GDScript tests and Python unit tests.
pytest: 385 passed | test_run: 332 passed, 0 failed

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

codecov Bot commented Apr 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

dsarno and others added 26 commits April 15, 2026 20:39
The CI script waited for a Godot session to connect but not for the
editor to actually open a scene. Tests that resolve scene paths would
get EDITOR_NOT_READY because readiness was still "no_scene".

Now polls editor_state readiness until it transitions to "ready"
(up to 60s) before running test_run.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Closes the patch coverage gap — the tool wrapper functions in
tools/animation.py now have integration tests exercising the full
MCP call path through the mock plugin.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Headless Godot (--headless --editor) does not auto-open the last scene.
After the readiness wait detects no_scene, explicitly call scene_open
via MCP to load main.tscn before running tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Headless Godot --editor no longer auto-opens the last scene on recent
CI runners. Pass res://main.tscn as a positional arg to force the scene
open on launch. Also log scene_open response for debugging.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Headless Godot --import creates .godot/ but does not record any open
scenes. When --editor launches, the editor_layout has no scenes to
reopen, leaving readiness stuck at no_scene.

Append an [EditorNode] section with open_scenes after import to ensure
main.tscn reopens on editor launch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previous fix appended to the import-generated layout, but Godot's
ConfigFile parser may not handle duplicate sections. Write a clean
minimal layout file with just the EditorNode section.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move scene_open call to right after session detection, before the
readiness poll loop. This gives the editor time to load the scene
while we wait for readiness to transition from no_scene to ready.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The cyberpunk HUD exercise (7cad020) committed a dirty main.tscn with
test-run leftovers: stale ext_resource refs to _mcp_test_material.tres
(does not exist in repo), ~20 test nodes, modified Camera3D, and the
HUDLoader script. project.godot also had stray GameState autoload and
ability_* input actions.

On CI, the missing resource caused scene_open to fail, keeping readiness
stuck at no_scene, so test_run returned 0 tests.

Removed: cyberpunk HUD files, test artifacts, stray autoloads/inputs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add debug prints to _discover_suites() and dump raw test_run response
in ci-godot-tests script.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When 0 suites found, include dir listing, gd file names, and per-file
load errors so CI raw dump shows exactly why discovery fails.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previous _debug_discovery() returned {} — likely a runtime error.
Inline the check directly to avoid function call issues.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
CACHE_MODE_IGNORE creates fresh script instances with different class
identities, causing typed Array[McpTestSuite] appends to silently fail
on CI where the class cache is freshly built by --import.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Root cause: calling scene_open via MCP before test_run in CI left the
headless editor in a state where _discover_suites() returned empty.
Possibly related to #10 (scene_open re-entrancy crash). On main (which
has no scene_open in CI, tests discover 312 suites successfully.

Changes:
- Remove scene_open + readiness wait from ci-godot-tests script
- Remove editor_layout.cfg force-write from ci.yml Linux job
- Use untyped Array in _discover_suites() to avoid potential typed
  array silent rejection with CACHE_MODE_IGNORE script loading
- Restore CACHE_MODE_IGNORE for dev workflow (pick up test changes)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
EOF
)
When 0 suites are found, return dir listing, load results, and
McpTestSuite check status so CI logs reveal the root cause.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Root cause: CACHE_MODE_IGNORE in _discover_suites() breaks class_name
resolution when test scripts reference plugin types (e.g.
`var _handler: ThemeHandler` in test_theme.gd). Fresh-loaded scripts
can't resolve these type declarations against the global class registry,
causing ResourceLoader.load() to silently crash. This only affects the
PR branch because main has no test files that reference the new
ThemeHandler/UiHandler classes.

Fix: use default cache mode so scripts resolve class_name types from the
cached global class registry. Use untyped Array for extra safety.
Remove scene_open additions from CI script and editor_layout.cfg hack
(tests don't need a scene open — main CI proves this.

Closes #10 (partially — removes scene_open from CI to avoid crash risk)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
EOF
)
When a GDScript handler crashes, .call() returns default empty dict.
Detect this and return a proper error so CI logs reveal the crash
instead of silently returning {}.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Reverts test_handler.gd, dispatcher.gd, ci-godot-tests, and ci.yml
to exactly match main. If CI still fails, the issue is in the PR's
feature code (handler changes, test files, scene cleanup), not in
the test infrastructure.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
These 22 extra .uid files were from a local Godot run. They may cause
the import step to register stale UIDs, corrupting class resolution
and breaking test discovery.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
dsarno and others added 11 commits April 16, 2026 07:11
test_editor.gd had two functions named test_screenshot_invalid_source
(lines 93 and 315). GDScript parse error on ResourceLoader.load()
crashed _discover_suites(), returning 0 tests on all CI platforms.

Also sync ci.yml Linux pip timeout from main.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three guardrails to prevent silent test discovery failures:

1. _discover_suites() now catches per-file load errors and continues.
   Returns {suites, errors} so the error message names the broken
   file instead of 'No test suites found'.

2. New script/ci-check-gdscript validates all .gd files for parse
   errors before the editor starts. Would have caught the duplicate
   function name in seconds.

3. CI workflow runs validation after --import, before --editor.
   Parse errors fail fast with clear output.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The barichello/godot-ci container ships with an old pip that hangs
on dependency resolution. Upgrading pip first and adding --timeout 60
should prevent the infinite hang.
barichello/godot-ci:4.6.2 hangs at pip install every run. Switch to
chickensoft-games/setup-godot + actions/setup-python on ubuntu-latest,
matching the macOS and Windows setup that works reliably.
- Extract _commit_animation_add() helper in animation_handler.gd to
  deduplicate the overwrite undo/redo block between create_animation
  and create_simple (was copy-pasted verbatim)
- Replace _resolve_autoload property list scan with O(1)
  ProjectSettings.has_setting() lookup
- Remove TOCTOU dir_exists check before make_dir_recursive in
  theme_handler.gd (mkdir is idempotent)
- Optimize ci-check-gdscript: reuse --import output instead of
  spawning one Godot process per .gd file

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

Sync CLAUDE.md, implementation-plan, testing-strategy, tool-taxonomy,
and skill file with recent changes: chickensoft CI runner, ci-check-gdscript
validation step, step timeouts, resilient test discovery, animation_delete
and animation_validate tools, overwrite parameter on animation_create,
node_create scene_path for scene instancing, rename_node root support,
theme_set_stylebox_flat per-side overrides, and updated test counts
(387 Python + 333 GDScript = 720 total).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@dsarno dsarno merged commit 64782fc into main Apr 16, 2026
12 checks passed
dsarno added a commit that referenced this pull request Apr 16, 2026
Nine follow-ups from a retroactive code review of the animation_* tool
family (#17) and the cyberpunk HUD friction fixes (#21). All 388 Python
+ 345 GDScript tests pass; smoke-tested live against the editor.

1. node_create scene_path now uses PackedScene.GEN_EDIT_STATE_INSTANCE
   so the editor treats the result as a real scene instance (foldout icon,
   .tscn stores a reference, not an exploded subtree). Removes the
   _set_owner_recursive call that was running outside the undo action and
   also broke the instance link by forcing descendant ownership.

2. Animation track undo no longer caches the baseline track_count at do
   time - it resolves the index via anim.find_track(path, type) at undo
   time. Robust to any history interleaving. New test stresses this path.

3. _coerce_value_for_track now errors with INVALID_PARAMS when the target
   node exists but the property doesnt - previously the raw value passed
   through and produced garbage keyframes at playback. Matches the
   handlers own docstring.

4. add_method_track validates keyframe args is an Array and method is a
   non-empty string. Previously a typed-assign crash or silent garbage.

5. animation_delete now uses _resolve_animation to locate the owning
   library, matching the read-side symmetry with animation_get /
   animation_play. Delete from non-default libraries works end-to-end.

6. theme_set_stylebox_flat flattened from 27 parameters to a nested-dict
   API: border / corners / margins / shadow each take {all, sides} with
   side-specific keys overriding all. Unknown keys fail loudly. Breaking
   change - tool was only merged in #21 and has no external clients yet.

7. project_stop replaces the fixed 150ms sleep with a bounded poll on
   session.readiness. Reaches truth as soon as the existing
   readiness_changed event arrives (~17ms) instead of blanket-sleeping
   150ms, with a 1s timeout for a hung play process.

8. ui_build_layout pseudo-property tests now read back via
   get_theme_*_override (not the fallback get_theme_* getters), per the
   CLAUDE.md "assert on the stored Variant" rule. Added missing
   theme_override_styles/ test.

9. signal_connect distinguishes declared-but-uninstantiated autoloads
   from genuinely missing nodes, with a clear error pointing users at
   @onready/runtime-connect as the workaround. Previous "not found"
   error masked the real reason most runtime-only autoloads cant be
   connected at edit time.

Also updates CLAUDE.md with reference patterns for find_track-at-undo,
GEN_EDIT_STATE_INSTANCE, and get_theme_*_override readback tests.

Deferred to a follow-up PR: plugin dispatcher coroutine support +
readiness Option C (plugin-side await). That refactor touches the hot
path every command flows through and deserves its own bisect-friendly
landing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
dsarno added a commit that referenced this pull request Apr 16, 2026
* Address retroactive review of PRs #17 and #21

Nine follow-ups from a retroactive code review of the animation_* tool
family (#17) and the cyberpunk HUD friction fixes (#21). All 388 Python
+ 345 GDScript tests pass; smoke-tested live against the editor.

1. node_create scene_path now uses PackedScene.GEN_EDIT_STATE_INSTANCE
   so the editor treats the result as a real scene instance (foldout icon,
   .tscn stores a reference, not an exploded subtree). Removes the
   _set_owner_recursive call that was running outside the undo action and
   also broke the instance link by forcing descendant ownership.

2. Animation track undo no longer caches the baseline track_count at do
   time - it resolves the index via anim.find_track(path, type) at undo
   time. Robust to any history interleaving. New test stresses this path.

3. _coerce_value_for_track now errors with INVALID_PARAMS when the target
   node exists but the property doesnt - previously the raw value passed
   through and produced garbage keyframes at playback. Matches the
   handlers own docstring.

4. add_method_track validates keyframe args is an Array and method is a
   non-empty string. Previously a typed-assign crash or silent garbage.

5. animation_delete now uses _resolve_animation to locate the owning
   library, matching the read-side symmetry with animation_get /
   animation_play. Delete from non-default libraries works end-to-end.

6. theme_set_stylebox_flat flattened from 27 parameters to a nested-dict
   API: border / corners / margins / shadow each take {all, sides} with
   side-specific keys overriding all. Unknown keys fail loudly. Breaking
   change - tool was only merged in #21 and has no external clients yet.

7. project_stop replaces the fixed 150ms sleep with a bounded poll on
   session.readiness. Reaches truth as soon as the existing
   readiness_changed event arrives (~17ms) instead of blanket-sleeping
   150ms, with a 1s timeout for a hung play process.

8. ui_build_layout pseudo-property tests now read back via
   get_theme_*_override (not the fallback get_theme_* getters), per the
   CLAUDE.md "assert on the stored Variant" rule. Added missing
   theme_override_styles/ test.

9. signal_connect distinguishes declared-but-uninstantiated autoloads
   from genuinely missing nodes, with a clear error pointing users at
   @onready/runtime-connect as the workaround. Previous "not found"
   error masked the real reason most runtime-only autoloads cant be
   connected at edit time.

Also updates CLAUDE.md with reference patterns for find_track-at-undo,
GEN_EDIT_STATE_INSTANCE, and get_theme_*_override readback tests.

Deferred to a follow-up PR: plugin dispatcher coroutine support +
readiness Option C (plugin-side await). That refactor touches the hot
path every command flows through and deserves its own bisect-friendly
landing.

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

* docs: bump test counts and reflect PR #28 API reshape

- CLAUDE.md: 387 → 388 Python tests
- implementation-plan.md: 387+333=720 → 388+345=733 total;
  theme_* blurb now describes the nested border/corners/margins/shadow
  dicts and animation undo-robust-to-history-interleaving

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

* simplify: hoist property-type resolution + use get_running_loop

1. animation_handler.gd: _coerce_value_for_track split into
   _resolve_track_prop_context (per-track) + _coerce_with_context (per-key).
   add_property_track resolves the target property type once before the
   keyframe loop instead of re-walking get_property_list() for every
   keyframe. Dense clips (100+ keys) avoid 100x the work. Existing API
   (_coerce_value_for_track) preserved for create_simple, which still
   resolves per-tween since target/property change per tween.

2. project.py: replace asyncio.get_event_loop() with get_running_loop()
   (former is deprecated in Python 3.12+ inside async functions).

All 388 Python + 345 GDScript tests pass.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
@dsarno dsarno deleted the friction/tier1-fixes branch April 18, 2026 00:00
dsarno added a commit that referenced this pull request Jun 9, 2026
…dator

The strong traversal validator from #347 was wired into only filesystem and
script handlers; ~12 other path-taking handlers used a bare begins_with("res://")
check (which does not reject "..") or no check at all (relying solely on
ResourceLoader.exists/load). This unifies all of them on McpPathValidator and
extends the validator with two checks.

Validator (utils/path_validator.gd):
- Reject embedded null bytes (truncation trap — the path written could differ
  from the one validated).
- New for_write flag: write callers additionally refuse res://project.godot,
  the res://.godot/ metadata dir, and .import sidecars (overwriting these
  corrupts the project / import cache). Reads still permit inspecting them.
  Signature is backward-compatible (for_write defaults to false).

Writers now validate with for_write=true (closes the traversal-write primitive):
  resource_io.save_to_disk (backs resource/environment/texture/curve saves),
  scene create_scene/save_scene_as, material/theme save helpers, filesystem
  write_text, script create/patch.

Load/read sites now validate (closes the unvalidated-load surface, incl. the
@tool-script-execution risk from open_scene/create_node):
  resource load/assign + nested object-property loads, scene open_scene,
  script attach_script, node create_node scene_path + set_property resource
  values, audio set_stream + list root, material assign/shader/list,
  ui theme + stylebox, autoload path, curve set_points, particle mesh/
  material/texture, material_values.load_texture.

Path-validation rejections report VALUE_OUT_OF_RANGE (the code these handlers
already used for "must start with res://"), keeping the INVALID_PARAMS catch-all
count under the audit-v2 #21 ceiling enforced by test_error_code_distribution.
The four pre-existing validator sites that already wrapped errors as
INVALID_PARAMS (filesystem, script create/read/patch/find, resource_io save,
material _validate_material_path) are left unchanged.

set_stream and the other load handlers now reject user:// (consistent with the
validator's existing test_user_prefix_rejected policy); the audio test fixture
moves from user:// to a res:// .tres registered via EditorFileSystem.update_file.

Tests (run against live Godot 4.6.3): validator unit tests for null-byte +
write blocklist + read-allows; scene traversal/manifest-overwrite rejection.
All affected suites pass (path_validator/scene/resource/node/material/theme/
curve/particle/ui/audio/autoload/filesystem/script). test_error_code_distribution
passes. Parse check clean.

Addresses advisory GHSA-p5x8-v25q-qw69 (GH-1, GH-2, GH-3, GH-4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
dsarno added a commit that referenced this pull request Jun 10, 2026
…dator

The strong traversal validator from #347 was wired into only filesystem and
script handlers; ~12 other path-taking handlers used a bare begins_with("res://")
check (which does not reject "..") or no check at all (relying solely on
ResourceLoader.exists/load). This unifies all of them on McpPathValidator and
extends the validator with two checks.

Validator (utils/path_validator.gd):
- Reject embedded null bytes (truncation trap — the path written could differ
  from the one validated).
- New for_write flag: write callers additionally refuse res://project.godot,
  the res://.godot/ metadata dir, and .import sidecars (overwriting these
  corrupts the project / import cache). Reads still permit inspecting them.
  Signature is backward-compatible (for_write defaults to false).

Writers now validate with for_write=true (closes the traversal-write primitive):
  resource_io.save_to_disk (backs resource/environment/texture/curve saves),
  scene create_scene/save_scene_as, material/theme save helpers, filesystem
  write_text, script create/patch.

Load/read sites now validate (closes the unvalidated-load surface, incl. the
@tool-script-execution risk from open_scene/create_node):
  resource load/assign + nested object-property loads, scene open_scene,
  script attach_script, node create_node scene_path + set_property resource
  values, audio set_stream + list root, material assign/shader/list,
  ui theme + stylebox, autoload path, curve set_points, particle mesh/
  material/texture, material_values.load_texture.

Path-validation rejections report VALUE_OUT_OF_RANGE (the code these handlers
already used for "must start with res://"), keeping the INVALID_PARAMS catch-all
count under the audit-v2 #21 ceiling enforced by test_error_code_distribution.
The four pre-existing validator sites that already wrapped errors as
INVALID_PARAMS (filesystem, script create/read/patch/find, resource_io save,
material _validate_material_path) are left unchanged.

set_stream and the other load handlers now reject user:// (consistent with the
validator's existing test_user_prefix_rejected policy); the audio test fixture
moves from user:// to a res:// .tres registered via EditorFileSystem.update_file.

Tests (run against live Godot 4.6.3): validator unit tests for null-byte +
write blocklist + read-allows; scene traversal/manifest-overwrite rejection.
All affected suites pass (path_validator/scene/resource/node/material/theme/
curve/particle/ui/audio/autoload/filesystem/script). test_error_code_distribution
passes. Parse check clean.

Addresses advisory GHSA-p5x8-v25q-qw69 (GH-1, GH-2, GH-3, GH-4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
dsarno added a commit that referenced this pull request Jun 10, 2026
…dator (#546)

* harden(handlers): route every path-taking handler through McpPathValidator

The strong traversal validator from #347 was wired into only filesystem and
script handlers; ~12 other path-taking handlers used a bare begins_with("res://")
check (which does not reject "..") or no check at all (relying solely on
ResourceLoader.exists/load). This unifies all of them on McpPathValidator and
extends the validator with two checks.

Validator (utils/path_validator.gd):
- Reject embedded null bytes (truncation trap — the path written could differ
  from the one validated).
- New for_write flag: write callers additionally refuse res://project.godot,
  the res://.godot/ metadata dir, and .import sidecars (overwriting these
  corrupts the project / import cache). Reads still permit inspecting them.
  Signature is backward-compatible (for_write defaults to false).

Writers now validate with for_write=true (closes the traversal-write primitive):
  resource_io.save_to_disk (backs resource/environment/texture/curve saves),
  scene create_scene/save_scene_as, material/theme save helpers, filesystem
  write_text, script create/patch.

Load/read sites now validate (closes the unvalidated-load surface, incl. the
@tool-script-execution risk from open_scene/create_node):
  resource load/assign + nested object-property loads, scene open_scene,
  script attach_script, node create_node scene_path + set_property resource
  values, audio set_stream + list root, material assign/shader/list,
  ui theme + stylebox, autoload path, curve set_points, particle mesh/
  material/texture, material_values.load_texture.

Path-validation rejections report VALUE_OUT_OF_RANGE (the code these handlers
already used for "must start with res://"), keeping the INVALID_PARAMS catch-all
count under the audit-v2 #21 ceiling enforced by test_error_code_distribution.
The four pre-existing validator sites that already wrapped errors as
INVALID_PARAMS (filesystem, script create/read/patch/find, resource_io save,
material _validate_material_path) are left unchanged.

set_stream and the other load handlers now reject user:// (consistent with the
validator's existing test_user_prefix_rejected policy); the audio test fixture
moves from user:// to a res:// .tres registered via EditorFileSystem.update_file.

Tests (run against live Godot 4.6.3): validator unit tests for null-byte +
write blocklist + read-allows; scene traversal/manifest-overwrite rejection.
All affected suites pass (path_validator/scene/resource/node/material/theme/
curve/particle/ui/audio/autoload/filesystem/script). test_error_code_distribution
passes. Parse check clean.

Addresses advisory GHSA-p5x8-v25q-qw69 (GH-1, GH-2, GH-3, GH-4).

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

* harden(handlers): address review — uid:// loads, case-fold blocklist, shared helper

Follow-up to the McpPathValidator unification, fixing the code-review findings.

Correctness:
- Restore uid:// and user:// support on load handlers. ResourceLoader accepts
  both (uid:// is an opaque resource id that can't express traversal; user://
  is the user data sandbox), but routing every load through the strict res://
  validator rejected them — a regression for uid:// refs copied from .tscn/.uid
  and for user:// runtime assets. New validate_loadable_path() accepts res://
  (confined), uid:// (as-is), and user:// (confined under the user root), and
  load sites now use it: set_property/assign_resource/resource property dicts,
  open_scene, create_node, attach_script, set_stream, particle mesh/material/
  texture, material assign/shader, ui theme/stylebox, curve, material_values.
- Case-fold the write blocklist. macOS (APFS) and Windows (NTFS) are
  case-insensitive by default, so res://Project.godot resolved to the real
  project.godot and slipped past a case-sensitive compare.
- Add override.cfg to the write blocklist (applied over project.godot at
  startup — same takeover surface as the manifest).
- Reads no longer run the write blocklist: _validate_material_path /
  _validate_res_path / _load_material_from_path / _load_theme_from_params take
  for_write, passed true only by the create/save callers. get_material and
  apply_theme (pure reads) no longer return a spurious "Refusing to write".
- Stop blocking .import writes — editing import config then reimporting is a
  legitimate, recoverable workflow; the blocklist is the startup-execution
  surface (manifest, override.cfg, .godot/) only.

Cleanup / altitude:
- Single error-code choke point: McpPathValidator.path_error / loadable_error
  return a ready error dict (MISSING_REQUIRED_PARAM for empty, VALUE_OUT_OF_RANGE
  for invalid) so every handler reports the same code for the same failure.
  filesystem/script move off their old INVALID_PARAMS wrapping onto this.
- curve set_points validates the load path (the save layer, resource_io.save_to_disk,
  remains the authoritative write-confinement check) instead of double-running
  the write validator.
- Drop the redundant is_empty() pre-check in material_values.load_texture.
- Document the lexical-containment (no symlink resolution) limitation in the
  validator — GDScript has no realpath; the loopback boundary is the control.

Tests (live Godot 4.6.3, all suites 0 failures): uid:// / user:// acceptance,
user:// traversal + unknown-scheme rejection, case-insensitive blocklist,
override.cfg block, .import now allowed; audio fixture moved back to user://
(no repo/EFS pollution). test_path_traversal_guard counts any McpPathValidator
delegation; missing-path tests now assert MISSING_REQUIRED_PARAM.

Addresses review findings 1-10 on GHSA-p5x8-v25q-qw69 (path confinement).

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

* harden(handlers): address Copilot review on path validation

- Guard the null-byte check against an empty String.chr(0) sentinel in both
  validate_resource_path and validate_loadable_path. On builds that normalize
  embedded nulls away (e.g. 4.3), contains("") would be true and reject every
  path; a String that can't hold a null can't smuggle one, so skip the check.
- Update stale "res:// path" comments in ui_handler (stylebox override) and
  material_values.load_texture — both now accept uid:// / user:// via
  validate_loadable_path.
- Tighten test_path_traversal_guard: assert attach_script is present and require
  >=5 McpPathValidator delegations in script_handler, so a regression where
  attach_script stops validating its path is caught.

Live Godot 4.6.3: path_validator/filesystem/script/ui/material/resource/scene/
audio suites all pass; test_path_traversal_guard green.

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

* style: wrap long func-name tuple in test_path_traversal_guard (ruff E501)

The 5-entry func list exceeded the 100-char line limit after adding
attach_script. Pure formatting; the assertion is unchanged.

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

* docs(handlers): align remaining res:// messages with uid:// / user:// support

Copilot re-review: now that the theme / stylebox / shader load paths accept
uid:// and user:// via loadable_error, the surrounding comments, the
build_layout docstring, the stylebox fallback error ("expects a res:// path"),
and the shader_path missing-arg error still said res:// only. Text-only.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant