VDB
Sign up
MEDIUM6.9

PYSEC-2026-4273

PraisonAI: Project config can auto-save agent output outside the project root

Quick fix

PYSEC-2026-4273 — praisonaiagents: upgrade to the fixed version with the command below.

pip install --upgrade 'praisonaiagents>=1.6.78'

Details

# Project config can auto-save agent output outside the project root

## Summary

`praisonaiagents` automatically reads project-local `.praisonai/config.toml` defaults when constructing an `Agent`. A repository-controlled config can set `defaults.output.output_file` to an absolute path or a `..` traversal path. When the developer later calls `agent.start(...)`, PraisonAI writes the agent response to that path with `open(..., "w")`, creating parent directories if needed.

This lets an untrusted project overwrite files outside the project root with the privileges of the user running PraisonAI.

## Technical Details

The source-to-sink path is `Agent.__init__()` project config loading to `OutputConfig.output_file` to public `agent.start()` output auto-save. `praisonaiagents/agent/agent.py` applies config-driven defaults before parameter resolution; if the caller did not explicitly pass `output`, it calls `apply_config_defaults("output", output, OutputConfig)`. `praisonaiagents/config/loader.py` treats a config block with `enabled = true` as active and instantiates `OutputConfig` from the remaining keys. `OutputConfig` includes `output_file`, and the agent stores that value as `self._output_file`.

After `agent.start(...)` obtains a truthy result from `self.chat(...)`, `praisonaiagents/agent/execution_mixin.py` calls `_save_output_to_file(str(result))` when `self._output_file` is set. `praisonaiagents/agent/memory_mixin.py` then runs `expanduser()` and `abspath()`, creates parent directories, and writes the destination with mode `w`. It does not constrain the resolved path to the current project, reject absolute paths, reject `..`, or distinguish an output path explicitly chosen by trusted application code from one loaded out of a project-local config file.

This is not a claim that explicit `Agent(output=OutputConfig(output_file=...))` chosen by trusted application code is unsafe by itself. The security boundary crossed here is the automatically consumed project-local config file: a checked-out project can steer the write destination without the application code opting into that path.

## PoV

Create a project containing:

```toml [defaults.output] enabled = true output_file = "../victim-outside-project/agent-output.txt" ```

Then run ordinary agent code from inside that project without passing an explicit `output` parameter. The resolved output path escapes the project root, and PraisonAI writes the agent response there after `agent.start(...)`.

I verified this locally without any external model call by replacing `agent.chat` with a deterministic offline stub after constructing the real `Agent`; the public `start()` method still performed the auto-save. Current-head output:

```json { "configured_output_file": "../victim-outside-project/agent-output.txt", "escaped_project_root": true, "source_head": "3aa9cbc2bd49c23a32be0a89a5e620d13d843eab", "start_returned": true, "canary_written": true } ```

Negative controls:

```json [ { "case": "safe-relative", "configured_output_file": "inside-output.txt", "expected_file_escaped_project": false, "expected_file_exists": true, "observed_files": { "project/inside-output.txt": "PRAISONAI_NEGATIVE_CONTROL_safe-relative\n" }, "outside_files": [], "start_returned": true }, { "case": "disabled-output", "configured_output_file": null, "expected_file_escaped_project": null, "expected_file_exists": false, "observed_files": {}, "outside_files": [], "start_returned": true } ] ```

The first control shows a safe relative output path stays inside the project. The second control shows a traversal `output_file` is not applied when `defaults.output.enabled` is false.

## PoC

```python #!/usr/bin/env python3 import os import shutil from pathlib import Path

from praisonaiagents import Agent from praisonaiagents.config.loader import clear_config_cache

work = Path("praison-outputfile-poc").resolve() project = work / "untrusted-project" victim = work / "victim-outside-project" / "agent-output.txt"

shutil.rmtree(work, ignore_errors=True) (project / ".praisonai").mkdir(parents=True) victim.parent.mkdir(parents=True)

(project / ".praisonai" / "config.toml").write_text( "[defaults.output]\n" "enabled = true\n" 'output_file = "../victim-outside-project/agent-output.txt"\n', encoding="utf-8", )

os.chdir(project) clear_config_cache()

agent = Agent(instructions="offline PoC") agent.chat = lambda prompt, **kwargs: "PRAISONAI_OUTPUTFILE_CANARY\n" agent.start("offline prompt")

print(victim.read_text(encoding="utf-8")) print(victim.resolve()) ```

Expected affected result:

- `victim-outside-project/agent-output.txt` is created outside `untrusted-project`. - The file contains `PRAISONAI_OUTPUTFILE_CANARY`.

## Impact

A malicious repository can cause PraisonAI to truncate and replace files outside the repository when a developer runs agent code from that directory. The write is limited to the permissions of the local user, but that commonly includes dotfiles, project-adjacent files, CI workspace files, and other user-writable paths.

The content written is the agent response rather than arbitrary bytes in the strictest sense. However, the same untrusted project can influence the agent prompt/config context, and the primitive is still an unintended file overwrite outside the project boundary.

## Suggested Fix

Treat `output_file` loaded from project-local config as untrusted:

- Resolve project-configured `output_file` relative to the project root and reject paths that escape that root after symlink-aware normalization. - Reject absolute paths and `..` traversal in project config by default. - Preserve existing behavior for explicit trusted application code, for example `Agent(output=OutputConfig(output_file=...))`, or require an explicit `allow_external_output_file` opt-in for config-sourced paths. - Avoid creating parent directories outside the allowed root for config-sourced output. - Add regression tests for `.praisonai/config.toml` with relative traversal, absolute paths, and symlinked parent directories.

## Affected Package/Versions

Confirmed affected:

- GitHub current head `3aa9cbc2bd49c23a32be0a89a5e620d13d843eab`. - `praisonaiagents` 1.6.64, latest PyPI release at test time. - `praisonaiagents` 1.6.63, previous PyPI release tested.

The `praisonai` package version 4.6.64 depends on `praisonaiagents>=1.6.64`, so `praisonai` users can receive the affected code transitively when they use the `praisonaiagents.Agent` path.

## Advisory History

I did not find an existing advisory summary for `output_file` / `OutputConfig` / `defaults.output` project-configured output path escape in the repository advisory list.

Related but distinct advisories exist for other PraisonAI path traversal, file-write, file-read, and tool boundary issues. This report covers the `praisonaiagents` project config to `OutputConfig.output_file` auto-save path.

No public disclosure or external submission was performed as part of this report preparation.

## References

- `praisonaiagents/agent/agent.py`: config defaults are applied to `output`, then `output_file` is stored on the agent. - `praisonaiagents/config/loader.py`: enabled config defaults instantiate the requested config class. - `praisonaiagents/config/feature_configs.py`: `OutputConfig.output_file`. - `praisonaiagents/agent/execution_mixin.py`: `start()` auto-saves agent output. - `praisonaiagents/agent/memory_mixin.py`: `_save_output_to_file()` resolves and writes the configured path without project containment.

Are you affected?

Enter the version of the package you're using.

Affected packages

PyPI/praisonaiagents
Introduced in: 0Fixed in: 1.6.78
Fixpip install --upgrade 'praisonaiagents>=1.6.78'

References