Skip to content

fix(agents): block directory traversal in command write paths (#2229)#2296

Merged
mnriem merged 1 commit intogithub:mainfrom
chordpli:fix/2229-alias-path-traversal
Apr 21, 2026
Merged

fix(agents): block directory traversal in command write paths (#2229)#2296
mnriem merged 1 commit intogithub:mainfrom
chordpli:fix/2229-alias-path-traversal

Conversation

@chordpli
Copy link
Copy Markdown
Contributor

Summary

Closes #2229.

Extends the alias containment guard from b67b285 (Goose PR #2015) to the two remaining write paths in CommandRegistrar that derive filenames from free-form command or alias names:

  • Primary command write in register_commands() — previously unguarded; a primary command name like /absolute/evil could escape commands_dir and attempt to create parent directories outside it.
  • write_copilot_prompt() — companion .prompt.md writes under .github/prompts/ previously had no containment check.

Both are now protected by a shared _ensure_inside() helper.

Design decision

Per maintainer guidance on #2229, this uses a lexical containment check (os.path.normpath + Path.is_relative_to) rather than a resolved-path check. Path traversal via .. or absolute paths is blocked at all three write sites, while intentionally symlinked sub-directories under an agent's commands directory (for example, .claude/skills/shared -> /team/shared-skills) remain supported to preserve backwards compatibility for existing extension setups.

This also aligns the existing alias guard from b67b285 with the same lexical policy so all three write sites behave consistently.

Tests

Added 22 regression cases in tests/test_registrar_path_traversal.py:

Area Cases Notes
Primary command name (gemini, copilot) 8 new coverage
Alias name (gemini, copilot) 8 regression for b67b285
write_copilot_prompt direct 4 public @staticmethod, new coverage
Safe baseline 1 positive regression
Symlinked sub-dir preserved 1 backward-compatibility contract; skips when symlink creation is not permitted

Payloads: ../pwned, ../../etc/passwd, subdir/../../escape, /absolute/evil.

Validated locally with:

  • uv run pytest -q tests/test_registrar_path_traversal.py
  • uv run pytest -q tests/test_extensions.py -k 'copilot_companion_prompt_created or copilot_aliases_get_companion_prompts or register_commands_for_copilot'
  • uvx ruff check src/specify_cli/agents.py tests/test_registrar_path_traversal.py

Out of scope

  • Pre-planted symlink attacks (an attacker would already need write access under commands_dir) — not addressed by the lexical containment policy chosen for backwards compatibility.
  • Primary command names coming from trusted ExtensionManager.install_from_directory() flows are still validated upstream by EXTENSION_COMMAND_NAME_PATTERN; this PR adds defense in depth for direct callers of the public register_commands() API.

…#2229)

Extend the alias containment guard from b67b285 to the two remaining
write paths that derive filenames from free-form command/alias names:

- Primary command write in CommandRegistrar.register_commands()
- CommandRegistrar.write_copilot_prompt()

Consolidate the check into a shared _ensure_inside() helper. Per
maintainer guidance on github#2229, use a lexical
(os.path.normpath + Path.is_relative_to) containment check rather than
resolve() so `..` / absolute-path traversal is rejected while
intentionally symlinked sub-directories under an agent's commands
directory (e.g. .claude/skills/shared -> /team/shared-skills) keep
working for existing extension setups.

Add 22 parametrised regression cases covering traversal payloads on
primary commands, aliases, and the Copilot companion prompt, plus a
positive case that confirms symlinked sub-directories remain supported.
@chordpli chordpli requested a review from mnriem as a code owner April 21, 2026 15:22
Copilot AI review requested due to automatic review settings April 21, 2026 15:22
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens CommandRegistrar against directory traversal by enforcing lexical “stay within base dir” checks for all command and Copilot prompt write paths, closing #2229 while preserving workflows that rely on symlinked subdirectories.

Changes:

  • Add a shared _ensure_inside() helper that performs lexical containment checks (normpath + Path.is_relative_to) and apply it to primary command writes, alias writes, and Copilot prompt writes.
  • Align the existing alias containment guard to the same lexical policy for consistent behavior across write sites.
  • Add a focused regression test suite covering traversal payloads, safe baselines, and symlinked-subdir compatibility.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
src/specify_cli/agents.py Centralizes and applies lexical containment checks to all relevant write targets in CommandRegistrar.
tests/test_registrar_path_traversal.py Adds regression tests ensuring traversal payloads are rejected and symlinked subdirs remain supported.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@mnriem mnriem merged commit 569d18a into github:main Apr 21, 2026
14 of 15 checks passed
@mnriem
Copy link
Copy Markdown
Collaborator

mnriem commented Apr 21, 2026

Thank you!

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.

security: alias path traversal in CommandRegistrar.register_commands() for flat agents

3 participants