Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docker/Dockerfile.chat
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.5.113" \
"praisonai>=4.5.114" \
"praisonai[chat]" \
"embedchain[github,youtube]"

Expand Down
2 changes: 1 addition & 1 deletion docker/Dockerfile.dev
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.5.113" \
"praisonai>=4.5.114" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
Expand Down
2 changes: 1 addition & 1 deletion docker/Dockerfile.ui
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.5.113" \
"praisonai>=4.5.114" \
"praisonai[ui]" \
"praisonai[crewai]"

Expand Down
15 changes: 14 additions & 1 deletion src/praisonai-agents/praisonaiagents/context/ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,20 @@ def __init__(self):
self._shared_tokens: int = 0 # Tokens shared across agents

def get_agent_ledger(self, agent_id: str) -> ContextLedgerManager:
"""Get or create ledger for an agent."""
"""Get or create ledger for an agent.

Args:
agent_id: Unique identifier for the agent.

Returns:
ContextLedgerManager for this agent.

Raises:
ValueError: If agent_id is empty or contains only whitespace.
"""
if not agent_id or not agent_id.strip():
raise ValueError("agent_id must be a non-empty string")

if agent_id not in self._agents:
self._agents[agent_id] = ContextLedgerManager(agent_id)
return self._agents[agent_id]
Expand Down
33 changes: 28 additions & 5 deletions src/praisonai-agents/praisonaiagents/context/monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -514,18 +514,41 @@ def snapshot(
return str(output_path)

def _get_output_path(self, agent_name: str = "") -> Path:
"""Get output path, optionally per-agent."""
"""Get output path, optionally per-agent.

Security: Agent names are sanitized to prevent path traversal.
"""
if not self.multi_agent_files or not agent_name:
return self.path

# Create per-agent path
stem = self.path.stem
suffix = self.path.suffix or (".json" if self.format == "json" else ".txt")

# Sanitize agent name
safe_name = re.sub(r'[^\w\-]', '_', agent_name.lower())

return self.path.parent / f"{stem}_{safe_name}{suffix}"
# Sanitize agent name:
# 1. Strip any path separators first (defence in depth)
safe_name = agent_name.replace('/', '_').replace('\\', '_')
# 2. Collapse to safe chars only
safe_name = re.sub(r'[^\w\-]', '_', safe_name.lower())
# 3. Enforce length limit to prevent filesystem issues
safe_name = safe_name[:64] if safe_name else 'unknown'
# 4. Strip leading/trailing underscores / dots
safe_name = safe_name.strip('_.')
if not safe_name:
safe_name = 'unknown'

result = self.path.parent / f"{stem}_{safe_name}{suffix}"

# Verify the resolved path stays within the expected parent dir
resolved = result.resolve()
parent_resolved = self.path.parent.resolve()
if not str(resolved).startswith(str(parent_resolved) + '/') and resolved.parent != parent_resolved:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The path validation logic uses string concatenation with a forward slash (/), which is not cross-platform compatible and will fail on Windows where the path separator is \. Since the project requires Python 3.10+, you should use the idiomatic is_relative_to method for a robust, platform-independent check.

Suggested change
if not str(resolved).startswith(str(parent_resolved) + '/') and resolved.parent != parent_resolved:
if not resolved.is_relative_to(parent_resolved):

Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

The directory containment check uses string prefix matching with a hard-coded '/' separator (startswith(str(parent_resolved) + '/')), which is not portable and can be brittle. Use resolved.is_relative_to(parent_resolved) / resolved.relative_to(parent_resolved) (Python >=3.9) or os.path.commonpath to verify the resolved path stays within the expected parent.

Suggested change
if not str(resolved).startswith(str(parent_resolved) + '/') and resolved.parent != parent_resolved:
try:
resolved.relative_to(parent_resolved)
except ValueError:

Copilot uses AI. Check for mistakes.
# Fallback to a safe hash-based name
import hashlib
hash_name = hashlib.sha256(agent_name.encode()).hexdigest()[:16]
result = self.path.parent / f"{stem}_{hash_name}{suffix}"

return result

def _get_warnings(
self,
Expand Down
14 changes: 12 additions & 2 deletions src/praisonai-agents/praisonaiagents/tools/python_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,15 @@ def safe_execute():
blocked_attrs = {{
'__subclasses__', '__bases__', '__mro__', '__globals__',
'__code__', '__class__', '__dict__', '__builtins__',
'__import__', '__loader__', '__spec__'
'__import__', '__loader__', '__spec__', '__init_subclass__',
'__set_name__', '__reduce__', '__reduce_ex__',
'__traceback__', '__qualname__', '__module__',
'__wrapped__', '__closure__', '__annotations__',
# Frame/code object introspection
'gi_frame', 'gi_code', 'cr_frame', 'cr_code',
'ag_frame', 'ag_code', 'tb_frame', 'tb_next',
'f_globals', 'f_locals', 'f_builtins', 'f_code',
'co_consts', 'co_names',
}}

for node in ast.walk(tree):
Expand All @@ -162,7 +170,9 @@ def safe_execute():
"success": False
}}
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
if node.func.id in ('exec', 'eval', 'compile', '__import__', 'open'):
if node.func.id in ('exec', 'eval', 'compile', '__import__',
'open', 'input', 'breakpoint',
'setattr', 'delattr', 'dir'):
Comment on lines +173 to +175
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

security-high high

The getattr function is missing from the blocked functions list. Since setattr and delattr are blocked to prevent object manipulation, getattr should also be blocked to prevent unauthorized access to sensitive attributes (like __globals__ or __subclasses__) that might bypass the blocked_attrs check if accessed dynamically.

Suggested change
if node.func.id in ('exec', 'eval', 'compile', '__import__',
'open', 'input', 'breakpoint',
'setattr', 'delattr', 'dir'):
if node.func.id in ('exec', 'eval', 'compile', '__import__',
'open', 'input', 'breakpoint', 'getattr',
'setattr', 'delattr', 'dir'):

return {{
"result": None,
"stdout": "",
Expand Down
2 changes: 1 addition & 1 deletion src/praisonai-agents/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "praisonaiagents"
version = "1.5.113"
version = "1.5.114"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
Expand Down
2 changes: 1 addition & 1 deletion src/praisonai-agents/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

42 changes: 38 additions & 4 deletions src/praisonai-ts/src/workflows/yaml-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import { AgentFlow, Task, TaskConfig } from './index';
import * as path from 'path';

export interface YAMLWorkflowDefinition {
name: string;
Expand Down Expand Up @@ -39,8 +40,9 @@ export interface ParsedWorkflow {
* Parse YAML string into workflow definition
*/
export function parseYAMLWorkflow(yamlContent: string): YAMLWorkflowDefinition {
// Simple YAML parser for workflow definitions
// For production, use js-yaml package
// SECURITY: If migrating to js-yaml, you MUST use:
// yaml.load(content, { schema: yaml.JSON_SCHEMA })
// Never use yaml.load() with DEFAULT_SCHEMA — it enables arbitrary JS execution.
const lines = yamlContent.split('\n');
const result: YAMLWorkflowDefinition = {
name: '',
Expand Down Expand Up @@ -82,7 +84,15 @@ export function parseYAMLWorkflow(yamlContent: string): YAMLWorkflowDefinition {
type: 'agent'
};
} else if (currentStep) {
// Step properties
// Step properties — whitelist allowed keys to prevent injection
const ALLOWED_STEP_KEYS = new Set([
'type', 'agent', 'tool', 'input', 'output', 'condition',
'onError', 'maxRetries', 'timeout', 'loopCondition', 'maxIterations',
]);
Comment on lines +88 to +91
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The ALLOWED_STEP_KEYS Set is being re-initialized on every iteration of the line-parsing loop. This is inefficient. It should be defined outside the loop or as a static constant to improve performance.

Comment on lines +87 to +91
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

ALLOWED_STEP_KEYS is re-created on every parsed step property line, which is unnecessary work and makes the loop harder to read. Move the Set constant outside the loop (module-level or function-level) so it’s created once.

Copilot uses AI. Check for mistakes.
if (!ALLOWED_STEP_KEYS.has(key)) {
// Ignore unknown keys — do not allow arbitrary property injection
continue;
}
if (key === 'type') currentStep.type = value as any;
else if (key === 'agent') currentStep.agent = value;
else if (key === 'tool') currentStep.tool = value;
Expand Down Expand Up @@ -265,9 +275,33 @@ function parseValue(value: string): any {
export async function loadWorkflowFromFile(
filePath: string,
agents: Record<string, any> = {},
tools: Record<string, any> = {}
tools: Record<string, any> = {},
options: { basePath?: string; maxFileSizeBytes?: number } = {}
): Promise<ParsedWorkflow> {
const fs = await import('fs/promises');

// SECURITY: Prevent path traversal
const normalizedPath = path.normalize(filePath);
if (normalizedPath.includes('..')) {
throw new Error('Path traversal detected: ".." is not allowed in file paths');
}
Comment on lines +283 to +287
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

The path traversal check normalizedPath.includes('..') can false-positive on filenames like foo..bar.yml and doesn’t ensure .. is a path segment. Instead, split normalizedPath by path.sep (and path.posix.sep if needed) and reject only segments equal to '..'.

Copilot uses AI. Check for mistakes.

// If basePath is specified, ensure resolvedPath stays within it
if (options.basePath) {
const resolvedBase = path.resolve(options.basePath);
const resolvedFile = path.resolve(options.basePath, normalizedPath);
if (!resolvedFile.startsWith(resolvedBase + path.sep) && resolvedFile !== resolvedBase) {
throw new Error(`File path must be within base directory: ${options.basePath}`);
}
}

// SECURITY: Enforce file size limit (default 1 MB)
const maxSize = options.maxFileSizeBytes ?? 1_048_576;
const stat = await fs.stat(filePath);
if (stat.size > maxSize) {
throw new Error(`File too large: ${stat.size} bytes exceeds limit of ${maxSize} bytes`);
}

const content = await fs.readFile(filePath, 'utf-8');
Comment on lines +292 to 305
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

security-high high

There are two issues here:

  1. Symlink Bypass: path.resolve does not resolve symbolic links. An attacker could provide a path to a symlink that points outside the basePath, bypassing the prefix check. Use fs.realpath() to resolve the actual path on disk before validation.
  2. CWD Mismatch: If basePath is provided, the code validates resolvedFile but then reads from filePath at line 305. If filePath is a relative path, fs.readFile will resolve it against the process's current working directory (CWD) rather than the intended basePath.

Comment on lines +289 to 305
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

The new options behavior (basePath restriction + file size limit) in loadWorkflowFromFile() isn’t covered by existing unit tests (there are tests for parseYAMLWorkflow, but none for file-loading). Add tests that assert traversal is rejected, basePath containment is enforced, and oversize files are rejected.

Suggested change
// If basePath is specified, ensure resolvedPath stays within it
if (options.basePath) {
const resolvedBase = path.resolve(options.basePath);
const resolvedFile = path.resolve(options.basePath, normalizedPath);
if (!resolvedFile.startsWith(resolvedBase + path.sep) && resolvedFile !== resolvedBase) {
throw new Error(`File path must be within base directory: ${options.basePath}`);
}
}
// SECURITY: Enforce file size limit (default 1 MB)
const maxSize = options.maxFileSizeBytes ?? 1_048_576;
const stat = await fs.stat(filePath);
if (stat.size > maxSize) {
throw new Error(`File too large: ${stat.size} bytes exceeds limit of ${maxSize} bytes`);
}
const content = await fs.readFile(filePath, 'utf-8');
let resolvedPath: string;
// If basePath is specified, ensure resolvedPath stays within it
if (options.basePath) {
const resolvedBase = path.resolve(options.basePath);
resolvedPath = path.resolve(resolvedBase, normalizedPath);
if (!resolvedPath.startsWith(resolvedBase + path.sep) && resolvedPath !== resolvedBase) {
throw new Error(`File path must be within base directory: ${options.basePath}`);
}
} else {
resolvedPath = path.resolve(normalizedPath);
}
// SECURITY: Enforce file size limit (default 1 MB)
const maxSize = options.maxFileSizeBytes ?? 1_048_576;
const stat = await fs.stat(resolvedPath);
if (stat.size > maxSize) {
throw new Error(`File too large: ${stat.size} bytes exceeds limit of ${maxSize} bytes`);
}
const content = await fs.readFile(resolvedPath, 'utf-8');

Copilot uses AI. Check for mistakes.
const definition = parseYAMLWorkflow(content);
Comment on lines +289 to 306
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

When options.basePath is provided, you validate resolvedFile but then call fs.stat(filePath) and fs.readFile(filePath). This means the code can read a different file than the one you validated (depending on CWD / relative paths). Use the same resolved/validated path for stat + read (and consider resolving even when basePath is not provided for consistency).

Copilot uses AI. Check for mistakes.
return createWorkflowFromYAML(definition, agents, tools);
Comment on lines +289 to 307
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

1. Basepath check not enforced 🐞 Bug ⛨ Security

In loadWorkflowFromFile(), the basePath guard validates a resolved path (resolvedFile) but then
size-checks and reads using the original filePath, so the code may read a different file than the
one validated (depending on process CWD). This undermines the intended basePath restriction and can
load unintended workflow files.
Agent Prompt
### Issue description
`loadWorkflowFromFile()` validates `resolvedFile` (based on `options.basePath` + `normalizedPath`) but then uses `filePath` for `fs.stat()` and `fs.readFile()`. This means the security check may not apply to the actual file being accessed.

### Issue Context
When `options.basePath` is provided, the function should consistently treat the file to load as `resolvedFile` (and ideally also return/operate on that canonical path).

### Fix Focus Areas
- src/praisonai-ts/src/workflows/yaml-parser.ts[283-307]

### Implementation notes
- Compute a single `const effectivePath = options.basePath ? resolvedFile : normalizedOrAbsolutePath`.
- Use `effectivePath` for `fs.stat()` and `fs.readFile()`.
- Consider also returning/using `effectivePath` for any subsequent logic so logs/errors refer to the canonical path.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Expand Down
4 changes: 2 additions & 2 deletions src/praisonai/praisonai.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ class Praisonai < Formula

desc "AI tools for various AI applications"
homepage "https://github.com/MervinPraison/PraisonAI"
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.113.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.113.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.114.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.5.114.tar.gz | shasum -a 256`.split.first
license "MIT"

depends_on "python@3.11"
Expand Down
117 changes: 110 additions & 7 deletions src/praisonai/praisonai/cli/features/agent_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,70 @@
from .code_intelligence import CodeIntelligenceRouter
from .action_orchestrator import ActionOrchestrator

import os
import re
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

re is imported but not used in this module, which will fail lint/type-check in most setups. Remove the unused import or use it where intended.

Suggested change
import re

Copilot uses AI. Check for mistakes.

logger = logging.getLogger(__name__)


def _sanitize_filepath(filepath: str, workspace: str = None) -> str:
"""Validate and sanitize a filepath against injection attacks.
Comment on lines +24 to +25
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

_sanitize_filepath annotates workspace as str but defaults it to None. This is an incorrect type hint; update it to Optional[str] (and import Optional) so static type checkers don’t report a mismatch.

Copilot uses AI. Check for mistakes.

Raises ValueError if the path is unsafe.
"""
# Reject null bytes (common injection technique)
if '\x00' in filepath:
raise ValueError("Null bytes are not allowed in file paths")

# Reject obvious shell meta-characters in file names
if any(ch in filepath for ch in ('|', ';', '`', '$', '&', '\n', '\r')):
raise ValueError(f"Unsafe characters in filepath: {filepath!r}")

# Normalize and reject path traversal
normalized = os.path.normpath(filepath)
if '..' in normalized.split(os.sep):
raise ValueError(f"Path traversal detected in: {filepath!r}")

# If we have a workspace, ensure the resolved path stays within it
if workspace:
resolved = os.path.realpath(os.path.join(workspace, normalized))
ws_resolved = os.path.realpath(workspace)
if not resolved.startswith(ws_resolved + os.sep) and resolved != ws_resolved:
raise ValueError(
f"Path {filepath!r} resolves outside workspace {workspace!r}"
)

return normalized


def _sanitize_command(command: str) -> str:
"""Basic command sanitization — reject common injection vectors.

The command still goes through ACP approval flow, but this prevents
the most egregious prompt-injection-to-shell-injection chains.

Raises ValueError if dangerous patterns are detected.
"""
if '\x00' in command:
raise ValueError("Null bytes are not allowed in commands")

# Reject command chaining operators that indicate injection
DANGEROUS_PATTERNS = [
'$(', '`', # Command substitution
'&&', '||', # Command chaining
'>>', '> ', # Output redirection
'| ', # Pipe
]
Comment on lines +66 to +71
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

security-high high

The DANGEROUS_PATTERNS blacklist is incomplete and easily bypassed. For example, it checks for '| ' (with a space) but not '|' (without a space), allowing command piping like ls|grep. It also misses critical shell metacharacters like ;, &, and newlines which are checked in _sanitize_filepath but omitted here.

    DANGEROUS_PATTERNS = [
        '$(', '`',      # Command substitution
        '&&', '||',     # Command chaining  
        '>>', '>',      # Output redirection
        '|', ';', '&',  # Pipe and separators
        '\n', '\r'      # Line breaks
    ]

for pattern in DANGEROUS_PATTERNS:
if pattern in command:
raise ValueError(
f"Potentially unsafe command pattern detected: {pattern!r} "
f"in command: {command!r}. Use separate commands instead."
)

return command


def create_agent_centric_tools(
runtime: "InteractiveRuntime",
router: "CodeIntelligenceRouter" = None,
Expand Down Expand Up @@ -77,8 +138,17 @@ def acp_create_file(filepath: str, content: str) -> str:
JSON string with result including plan status and verification
"""
async def _create():
# Validate filepath
try:
safe_path = _sanitize_filepath(
filepath,
workspace=getattr(runtime.config, 'workspace', None)
)
except ValueError as e:
return json.dumps({"success": False, "error": str(e)})

# Build a detailed prompt for the orchestrator
prompt = f"create file {filepath}"
prompt = f"create file {safe_path}"

# Create plan
result = await orchestrator.create_plan(prompt)
Expand Down Expand Up @@ -141,7 +211,15 @@ def acp_edit_file(filepath: str, new_content: str) -> str:
JSON string with result including plan status
"""
async def _edit():
prompt = f"edit file {filepath}"
try:
safe_path = _sanitize_filepath(
filepath,
workspace=getattr(runtime.config, 'workspace', None)
)
except ValueError as e:
return json.dumps({"success": False, "error": str(e)})

prompt = f"edit file {safe_path}"

result = await orchestrator.create_plan(prompt)
if not result.success:
Expand Down Expand Up @@ -189,7 +267,15 @@ def acp_delete_file(filepath: str) -> str:
JSON string with result
"""
async def _delete():
prompt = f"delete file {filepath}"
try:
safe_path = _sanitize_filepath(
filepath,
workspace=getattr(runtime.config, 'workspace', None)
)
except ValueError as e:
return json.dumps({"success": False, "error": str(e)})

prompt = f"delete file {safe_path}"

result = await orchestrator.create_plan(prompt)
if not result.success:
Expand Down Expand Up @@ -234,7 +320,12 @@ def acp_execute_command(command: str, cwd: str = None) -> str:
JSON string with command output
"""
async def _execute():
prompt = f"run command: {command}"
try:
safe_cmd = _sanitize_command(command)
except ValueError as e:
return json.dumps({"success": False, "error": str(e)})

prompt = f"run command: {safe_cmd}"

result = await orchestrator.create_plan(prompt)
if not result.success:
Expand Down Expand Up @@ -414,10 +505,16 @@ def read_file(filepath: str) -> str:
if not path.is_absolute():
path = Path(runtime.config.workspace) / filepath

if not path.exists():
# SECURITY: Ensure resolved path stays within workspace
resolved = path.resolve()
ws_resolved = Path(runtime.config.workspace).resolve()
if not str(resolved).startswith(str(ws_resolved) + os.sep) and resolved != ws_resolved:
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

The workspace escape check uses str(resolved).startswith(str(ws_resolved) + os.sep), which is error-prone across platforms (case-insensitive FS, separator differences) and can be brittle. Prefer resolved.relative_to(ws_resolved) / Path.is_relative_to() (or os.path.commonpath) to enforce containment robustly.

Suggested change
if not str(resolved).startswith(str(ws_resolved) + os.sep) and resolved != ws_resolved:
try:
resolved.relative_to(ws_resolved)
except ValueError:

Copilot uses AI. Check for mistakes.
return json.dumps({"error": f"Path escapes workspace: {filepath}"})

if not resolved.exists():
return json.dumps({"error": f"File not found: {filepath}"})

content = path.read_text()
content = resolved.read_text()

# Track in trace if enabled
if runtime._trace:
Expand Down Expand Up @@ -451,7 +548,13 @@ def list_files(directory: str = ".", pattern: str = "*") -> str:
if not path.is_absolute():
path = Path(runtime.config.workspace) / directory

if not path.exists():
# SECURITY: Ensure resolved path stays within workspace
resolved = path.resolve()
ws_resolved = Path(runtime.config.workspace).resolve()
if not str(resolved).startswith(str(ws_resolved) + os.sep) and resolved != ws_resolved:
return json.dumps({"error": f"Directory escapes workspace: {directory}"})

if not resolved.exists():
return json.dumps({"error": f"Directory not found: {directory}"})

files = []
Expand Down
Loading
Loading