Harden command registration path handling#3088
Conversation
CommandRegistrar.register_commands() read each command body from source_dir / cmd_file without validating the manifest 'file' field, unlike the parallel skill and preset readers which already reject absolute paths and '..' traversal. A malicious extension/preset/bundle manifest with file: ../../../etc/passwd (or an absolute path) could read arbitrary host files verbatim into a generated agent command at a predictable path (GHSA-w5fv-7w9x-7fc5, CWE-22). Add the same containment guard at the command read site and reject a traversal/absolute 'file' at manifest-load time in ExtensionManifest._validate() for defense-in-depth, plus regression tests for both the read path and the manifest validator. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR hardens extension command registration against path traversal and absolute-path reads via the manifest provides.commands[].file field, preventing arbitrary host files from being copied into generated agent command outputs. It adds both runtime containment checks and manifest-load validation, plus regression tests for the file read path.
Changes:
- Add containment checks in
CommandRegistrar.register_commands()to ensure the resolvedfilepath stays withinsource_dir. - Validate
provides.commands[].fileduringExtensionManifestload to reject absolute paths and..segments with a clearValidationError. - Add/extend tests to ensure traversal and absolute
filevalues do not register commands or leak external file contents.
Show a summary per file
| File | Description |
|---|---|
src/specify_cli/agents.py |
Adds source-file path containment guard before reading command bodies. |
src/specify_cli/extensions.py |
Adds defense-in-depth manifest validation for traversal/absolute file values. |
tests/test_registrar_path_traversal.py |
Adds regression tests ensuring file traversal/absolute paths don’t leak host content into generated commands. |
tests/test_extensions.py |
Adds manifest-load validation tests for invalid file values. |
Copilot's findings
Tip
Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comments suppressed due to low confidence (1)
src/specify_cli/agents.py:568
register_commands()checkssource_file.exists()then callsread_text(), which will raise (e.g.,IsADirectoryError,UnicodeDecodeError) if a manifest pointsfileat a directory or a non-UTF8 file. This can crash command registration; other readers in the codebase useis_file()and/or catch read errors. Consider aligning here by requiringsource_file.is_file()and handling read errors by skipping the command.
if not source_file.exists():
continue
content = source_file.read_text(encoding="utf-8")
- Files reviewed: 4/4 changed files
- Comments generated: 2
- register_commands(): use is_file() instead of exists() and skip the command if read_text() raises (directory or non-UTF8 file), aligning with the other command/skill readers. - Traversal tests: point the absolute-path payload at the real temp secret.txt (guaranteed to exist on all platforms) instead of /etc/passwd, so the absolute-path guard is genuinely exercised and the test fails if it regresses, rather than passing because the target happens not to exist (e.g. on Windows runners). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Addressed the review feedback in b2e71f5:
Full affected suites pass (318 passed). Posted on behalf of @mnriem by GitHub Copilot CLI (model: Claude Opus 4.8). |
…positive The regression fixtures named an out-of-tree file secret.txt with TOP-SECRET-CREDENTIAL content. CodeQL's clear-text-storage heuristic treated that read content as sensitive and followed the static path into the pre-existing write_text sinks in _write_registered_output, raising false 'clear-text storage of sensitive information' alerts on PR 3088. Rename the fixtures to neutral outside.txt / OUTSIDE-FILE-MARKER and drop /etc/passwd payloads; the test semantics (a file outside source_dir must never be read into a generated command) are unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
is_absolute() is False for Windows drive-relative paths like C:outside.txt, which contain no '..' yet resolve against the process CWD on that drive — bypassing the containment guard on Windows. Evaluate the 'file' value under PureWindowsPath as well so both the registrar runtime guard and the manifest-load validator reject drive letters (and backslash '..' segments) cross-platform. Extend the regression tests with drive-relative cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Addressed the Windows drive-relative edge case in 3d5f039.
323 tests pass (up from 318). Posted on behalf of @mnriem by GitHub Copilot CLI (model: Claude Opus 4.8). |
… on Windows
On a Windows runner WindowsPath('/abs/outside.md').is_absolute() is False
(no drive), so the prior native-Path check let a leading-slash 'file' value
through and the manifest validator did not raise. Evaluate the value under
both PurePosixPath and PureWindowsPath and reject any non-empty anchor —
covering POSIX-absolute, Windows drive-relative, Windows absolute, and
rooted-without-drive — in both the registrar guard and the manifest
validator. The registrar join now uses the raw 'file' string so native
separators are handled by the resolve()/relative_to() containment check.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
CommandRegistrar.register_commands() read each command body from source_dir / cmd_file without validating the manifest 'file' field, unlike the parallel skill and preset readers which already reject absolute paths and '..' traversal. A malicious extension/preset/bundle manifest with file: ../../../etc/passwd (or an absolute path) could read arbitrary host files verbatim into a generated agent command at a predictable path (GHSA-w5fv-7w9x-7fc5, CWE-22). Add the same containment guard at the command read site and reject a traversal/absolute 'file' at manifest-load time in ExtensionManifest._validate() for defense-in-depth, plus regression tests for both the read path and the manifest validator. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- register_commands(): use is_file() instead of exists() and skip the command if read_text() raises (directory or non-UTF8 file), aligning with the other command/skill readers. - Traversal tests: point the absolute-path payload at the real temp secret.txt (guaranteed to exist on all platforms) instead of /etc/passwd, so the absolute-path guard is genuinely exercised and the test fails if it regresses, rather than passing because the target happens not to exist (e.g. on Windows runners). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…positive The regression fixtures named an out-of-tree file secret.txt with TOP-SECRET-CREDENTIAL content. CodeQL's clear-text-storage heuristic treated that read content as sensitive and followed the static path into the pre-existing write_text sinks in _write_registered_output, raising false 'clear-text storage of sensitive information' alerts on PR 3088. Rename the fixtures to neutral outside.txt / OUTSIDE-FILE-MARKER and drop /etc/passwd payloads; the test semantics (a file outside source_dir must never be read into a generated command) are unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
is_absolute() is False for Windows drive-relative paths like C:outside.txt, which contain no '..' yet resolve against the process CWD on that drive — bypassing the containment guard on Windows. Evaluate the 'file' value under PureWindowsPath as well so both the registrar runtime guard and the manifest-load validator reject drive letters (and backslash '..' segments) cross-platform. Extend the regression tests with drive-relative cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… on Windows
On a Windows runner WindowsPath('/abs/outside.md').is_absolute() is False
(no drive), so the prior native-Path check let a leading-slash 'file' value
through and the manifest validator did not raise. Evaluate the value under
both PurePosixPath and PureWindowsPath and reject any non-empty anchor —
covering POSIX-absolute, Windows drive-relative, Windows absolute, and
rooted-without-drive — in both the registrar guard and the manifest
validator. The registrar join now uses the raw 'file' string so native
separators are handled by the resolve()/relative_to() containment check.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ix-command-path-traversal
… validation Address review feedback on github#3088: - register_commands(): skip non-string/empty 'file' values instead of raising TypeError, and hoist source_dir.resolve() out of the per-command loop. - ExtensionManifest._validate(): reject 'file' values with leading/trailing whitespace with a clear ValidationError instead of a confusing missing-file failure later. - tests: add non-string 'file' and whitespace cases; use yaml.safe_dump with explicit utf-8 encoding in the manifest validation test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Addressed this round in c4855b4:
330 affected tests pass (full suite also green on the prior rebase). Posted on behalf of @mnriem by GitHub Copilot CLI (model: Claude Opus 4.8). |
Address review feedback on github#3088: - register_commands(): also reject '..' segments under both POSIX and Windows semantics, keeping runtime policy consistent with ExtensionManifest._validate() and the skill/preset readers (not just relying on the resolve()/relative_to() containment backstop). - Replace the version-dependent is_absolute() claim in the extensions.py comment with the actual portability rationale (native Path is OS- dependent; C:foo is anchored but not absolute). - Extract the duplicated leak-detection assertion into _assert_no_marker_leak() and add an in-bounds '..' payload that exercises the new runtime '..' rejection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Addressed this round in 7b9a113:
332 affected tests pass. Posted on behalf of @mnriem by GitHub Copilot CLI (model: Claude Opus 4.8). |
Introduce relative_extension_path_violation() in _utils.py as the single source of truth for the extension-relative `file` path-safety policy, and use it from both the runtime registrar guard (agents.py) and the manifest-load validator (extensions.py) so the two cannot drift. Warn (instead of silently skipping) when an in-bounds command file exists but cannot be read/decoded, surfacing misconfigured extensions. Add unit tests for the shared helper, a read-skip warning test, and make the in-bounds `..` test create its target file so the skip is attributable to the `..` rejection rather than file absence. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Addressed review #4544960266 in 85246cc.
Affected suites green locally (349 passed). Posted on behalf of @mnriem by GitHub Copilot CLI (model: Claude Opus 4.8). |
Empty commit to re-trigger code scanning / CodeQL analysis on the PR merge ref. Assisted-by: GitHub Copilot CLI (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
What this fixes
CommandRegistrar.register_commands()read each command body fromsource_dir / cmd_fileusing the manifest'sfilefield without validating it. Unlike the parallel skill and preset readers — which already reject absolute paths and..traversal and require the resolved path to stay within the extension directory — the shared command path applied no such check.As a result, a manifest with
file: ../../../etc/passwd(or a bare absolute path) could cause an arbitrary host file to be read verbatim into a generated agent command file at a predictable path under the project (e.g..claude/commands/..., mirrored for each detected agent).Changes
agents.py—register_commands(): add the same containment guard used by the skill/preset readers before reading the source file — reject absolute paths and ensure the resolved path stays withinsource_dir; skip the command otherwise.extensions.py—ExtensionManifest._validate(): reject an absolute or..-containingfilevalue at manifest-load time with a clearValidationError(defense-in-depth, surfaces a real error instead of a silent skip).test_registrar_path_traversal.py: newTestCommandFileTraversalcovering thefileread path (the existing tests only covered command name/alias output traversal). Asserts traversal/absolutefilevalues register nothing and never leak file contents into generated commands.test_extensions.py: parametrized manifest-load validation test for thefilefield.Verification
New and targeted suites pass. Full suite: 3975 passed, with 2 pre-existing failures in
test_init_dir.py(aSPECIFY_INIT_DIRcore-scripts environment issue) confirmed unrelated to these changes by stashing.