Skip to content

Commit cc0544a

Browse files
committed
feat(context): enhance agent ledger and monitor functionality
- Improved `get_agent_ledger` method in `ledger.py` by adding validation for `agent_id` to raise a `ValueError` if it is empty. - Updated `_get_output_path` in `monitor.py` to include detailed path sanitization steps and added checks to prevent path traversal attacks. - Refined agent name sanitation and fallback techniques to ensure unique naming. - Incremented version number to `1.5.114` in `pyproject.toml` and `uv.lock`.
1 parent b845520 commit cc0544a

8 files changed

Lines changed: 245 additions & 21 deletions

File tree

src/praisonai-agents/praisonaiagents/context/ledger.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,20 @@ def __init__(self):
291291
self._shared_tokens: int = 0 # Tokens shared across agents
292292

293293
def get_agent_ledger(self, agent_id: str) -> ContextLedgerManager:
294-
"""Get or create ledger for an agent."""
294+
"""Get or create ledger for an agent.
295+
296+
Args:
297+
agent_id: Unique identifier for the agent.
298+
299+
Returns:
300+
ContextLedgerManager for this agent.
301+
302+
Raises:
303+
ValueError: If agent_id is empty or contains only whitespace.
304+
"""
305+
if not agent_id or not agent_id.strip():
306+
raise ValueError("agent_id must be a non-empty string")
307+
295308
if agent_id not in self._agents:
296309
self._agents[agent_id] = ContextLedgerManager(agent_id)
297310
return self._agents[agent_id]

src/praisonai-agents/praisonaiagents/context/monitor.py

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -514,18 +514,41 @@ def snapshot(
514514
return str(output_path)
515515

516516
def _get_output_path(self, agent_name: str = "") -> Path:
517-
"""Get output path, optionally per-agent."""
517+
"""Get output path, optionally per-agent.
518+
519+
Security: Agent names are sanitized to prevent path traversal.
520+
"""
518521
if not self.multi_agent_files or not agent_name:
519522
return self.path
520523

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

525-
# Sanitize agent name
526-
safe_name = re.sub(r'[^\w\-]', '_', agent_name.lower())
527-
528-
return self.path.parent / f"{stem}_{safe_name}{suffix}"
528+
# Sanitize agent name:
529+
# 1. Strip any path separators first (defence in depth)
530+
safe_name = agent_name.replace('/', '_').replace('\\', '_')
531+
# 2. Collapse to safe chars only
532+
safe_name = re.sub(r'[^\w\-]', '_', safe_name.lower())
533+
# 3. Enforce length limit to prevent filesystem issues
534+
safe_name = safe_name[:64] if safe_name else 'unknown'
535+
# 4. Strip leading/trailing underscores / dots
536+
safe_name = safe_name.strip('_.')
537+
if not safe_name:
538+
safe_name = 'unknown'
539+
540+
result = self.path.parent / f"{stem}_{safe_name}{suffix}"
541+
542+
# Verify the resolved path stays within the expected parent dir
543+
resolved = result.resolve()
544+
parent_resolved = self.path.parent.resolve()
545+
if not str(resolved).startswith(str(parent_resolved) + '/') and resolved.parent != parent_resolved:
546+
# Fallback to a safe hash-based name
547+
import hashlib
548+
hash_name = hashlib.sha256(agent_name.encode()).hexdigest()[:16]
549+
result = self.path.parent / f"{stem}_{hash_name}{suffix}"
550+
551+
return result
529552

530553
def _get_warnings(
531554
self,

src/praisonai-agents/praisonaiagents/tools/python_tools.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,15 @@ def safe_execute():
143143
blocked_attrs = {{
144144
'__subclasses__', '__bases__', '__mro__', '__globals__',
145145
'__code__', '__class__', '__dict__', '__builtins__',
146-
'__import__', '__loader__', '__spec__'
146+
'__import__', '__loader__', '__spec__', '__init_subclass__',
147+
'__set_name__', '__reduce__', '__reduce_ex__',
148+
'__traceback__', '__qualname__', '__module__',
149+
'__wrapped__', '__closure__', '__annotations__',
150+
# Frame/code object introspection
151+
'gi_frame', 'gi_code', 'cr_frame', 'cr_code',
152+
'ag_frame', 'ag_code', 'tb_frame', 'tb_next',
153+
'f_globals', 'f_locals', 'f_builtins', 'f_code',
154+
'co_consts', 'co_names',
147155
}}
148156
149157
for node in ast.walk(tree):
@@ -162,7 +170,9 @@ def safe_execute():
162170
"success": False
163171
}}
164172
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
165-
if node.func.id in ('exec', 'eval', 'compile', '__import__', 'open'):
173+
if node.func.id in ('exec', 'eval', 'compile', '__import__',
174+
'open', 'input', 'breakpoint',
175+
'setattr', 'delattr', 'dir'):
166176
return {{
167177
"result": None,
168178
"stdout": "",

src/praisonai-agents/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "praisonaiagents"
7-
version = "1.5.113"
7+
version = "1.5.114"
88
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
99
readme = "README.md"
1010
requires-python = ">=3.10"

src/praisonai-agents/uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/praisonai-ts/src/workflows/yaml-parser.ts

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
*/
55

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

89
export interface YAMLWorkflowDefinition {
910
name: string;
@@ -39,8 +40,9 @@ export interface ParsedWorkflow {
3940
* Parse YAML string into workflow definition
4041
*/
4142
export function parseYAMLWorkflow(yamlContent: string): YAMLWorkflowDefinition {
42-
// Simple YAML parser for workflow definitions
43-
// For production, use js-yaml package
43+
// SECURITY: If migrating to js-yaml, you MUST use:
44+
// yaml.load(content, { schema: yaml.JSON_SCHEMA })
45+
// Never use yaml.load() with DEFAULT_SCHEMA — it enables arbitrary JS execution.
4446
const lines = yamlContent.split('\n');
4547
const result: YAMLWorkflowDefinition = {
4648
name: '',
@@ -82,7 +84,15 @@ export function parseYAMLWorkflow(yamlContent: string): YAMLWorkflowDefinition {
8284
type: 'agent'
8385
};
8486
} else if (currentStep) {
85-
// Step properties
87+
// Step properties — whitelist allowed keys to prevent injection
88+
const ALLOWED_STEP_KEYS = new Set([
89+
'type', 'agent', 'tool', 'input', 'output', 'condition',
90+
'onError', 'maxRetries', 'timeout', 'loopCondition', 'maxIterations',
91+
]);
92+
if (!ALLOWED_STEP_KEYS.has(key)) {
93+
// Ignore unknown keys — do not allow arbitrary property injection
94+
continue;
95+
}
8696
if (key === 'type') currentStep.type = value as any;
8797
else if (key === 'agent') currentStep.agent = value;
8898
else if (key === 'tool') currentStep.tool = value;
@@ -265,9 +275,33 @@ function parseValue(value: string): any {
265275
export async function loadWorkflowFromFile(
266276
filePath: string,
267277
agents: Record<string, any> = {},
268-
tools: Record<string, any> = {}
278+
tools: Record<string, any> = {},
279+
options: { basePath?: string; maxFileSizeBytes?: number } = {}
269280
): Promise<ParsedWorkflow> {
270281
const fs = await import('fs/promises');
282+
283+
// SECURITY: Prevent path traversal
284+
const normalizedPath = path.normalize(filePath);
285+
if (normalizedPath.includes('..')) {
286+
throw new Error('Path traversal detected: ".." is not allowed in file paths');
287+
}
288+
289+
// If basePath is specified, ensure resolvedPath stays within it
290+
if (options.basePath) {
291+
const resolvedBase = path.resolve(options.basePath);
292+
const resolvedFile = path.resolve(options.basePath, normalizedPath);
293+
if (!resolvedFile.startsWith(resolvedBase + path.sep) && resolvedFile !== resolvedBase) {
294+
throw new Error(`File path must be within base directory: ${options.basePath}`);
295+
}
296+
}
297+
298+
// SECURITY: Enforce file size limit (default 1 MB)
299+
const maxSize = options.maxFileSizeBytes ?? 1_048_576;
300+
const stat = await fs.stat(filePath);
301+
if (stat.size > maxSize) {
302+
throw new Error(`File too large: ${stat.size} bytes exceeds limit of ${maxSize} bytes`);
303+
}
304+
271305
const content = await fs.readFile(filePath, 'utf-8');
272306
const definition = parseYAMLWorkflow(content);
273307
return createWorkflowFromYAML(definition, agents, tools);

src/praisonai/praisonai/cli/features/agent_tools.py

Lines changed: 110 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,70 @@
1515
from .code_intelligence import CodeIntelligenceRouter
1616
from .action_orchestrator import ActionOrchestrator
1717

18+
import os
19+
import re
20+
1821
logger = logging.getLogger(__name__)
1922

2023

24+
def _sanitize_filepath(filepath: str, workspace: str = None) -> str:
25+
"""Validate and sanitize a filepath against injection attacks.
26+
27+
Raises ValueError if the path is unsafe.
28+
"""
29+
# Reject null bytes (common injection technique)
30+
if '\x00' in filepath:
31+
raise ValueError("Null bytes are not allowed in file paths")
32+
33+
# Reject obvious shell meta-characters in file names
34+
if any(ch in filepath for ch in ('|', ';', '`', '$', '&', '\n', '\r')):
35+
raise ValueError(f"Unsafe characters in filepath: {filepath!r}")
36+
37+
# Normalize and reject path traversal
38+
normalized = os.path.normpath(filepath)
39+
if '..' in normalized.split(os.sep):
40+
raise ValueError(f"Path traversal detected in: {filepath!r}")
41+
42+
# If we have a workspace, ensure the resolved path stays within it
43+
if workspace:
44+
resolved = os.path.realpath(os.path.join(workspace, normalized))
45+
ws_resolved = os.path.realpath(workspace)
46+
if not resolved.startswith(ws_resolved + os.sep) and resolved != ws_resolved:
47+
raise ValueError(
48+
f"Path {filepath!r} resolves outside workspace {workspace!r}"
49+
)
50+
51+
return normalized
52+
53+
54+
def _sanitize_command(command: str) -> str:
55+
"""Basic command sanitization — reject common injection vectors.
56+
57+
The command still goes through ACP approval flow, but this prevents
58+
the most egregious prompt-injection-to-shell-injection chains.
59+
60+
Raises ValueError if dangerous patterns are detected.
61+
"""
62+
if '\x00' in command:
63+
raise ValueError("Null bytes are not allowed in commands")
64+
65+
# Reject command chaining operators that indicate injection
66+
DANGEROUS_PATTERNS = [
67+
'$(', '`', # Command substitution
68+
'&&', '||', # Command chaining
69+
'>>', '> ', # Output redirection
70+
'| ', # Pipe
71+
]
72+
for pattern in DANGEROUS_PATTERNS:
73+
if pattern in command:
74+
raise ValueError(
75+
f"Potentially unsafe command pattern detected: {pattern!r} "
76+
f"in command: {command!r}. Use separate commands instead."
77+
)
78+
79+
return command
80+
81+
2182
def create_agent_centric_tools(
2283
runtime: "InteractiveRuntime",
2384
router: "CodeIntelligenceRouter" = None,
@@ -77,8 +138,17 @@ def acp_create_file(filepath: str, content: str) -> str:
77138
JSON string with result including plan status and verification
78139
"""
79140
async def _create():
141+
# Validate filepath
142+
try:
143+
safe_path = _sanitize_filepath(
144+
filepath,
145+
workspace=getattr(runtime.config, 'workspace', None)
146+
)
147+
except ValueError as e:
148+
return json.dumps({"success": False, "error": str(e)})
149+
80150
# Build a detailed prompt for the orchestrator
81-
prompt = f"create file {filepath}"
151+
prompt = f"create file {safe_path}"
82152

83153
# Create plan
84154
result = await orchestrator.create_plan(prompt)
@@ -141,7 +211,15 @@ def acp_edit_file(filepath: str, new_content: str) -> str:
141211
JSON string with result including plan status
142212
"""
143213
async def _edit():
144-
prompt = f"edit file {filepath}"
214+
try:
215+
safe_path = _sanitize_filepath(
216+
filepath,
217+
workspace=getattr(runtime.config, 'workspace', None)
218+
)
219+
except ValueError as e:
220+
return json.dumps({"success": False, "error": str(e)})
221+
222+
prompt = f"edit file {safe_path}"
145223

146224
result = await orchestrator.create_plan(prompt)
147225
if not result.success:
@@ -189,7 +267,15 @@ def acp_delete_file(filepath: str) -> str:
189267
JSON string with result
190268
"""
191269
async def _delete():
192-
prompt = f"delete file {filepath}"
270+
try:
271+
safe_path = _sanitize_filepath(
272+
filepath,
273+
workspace=getattr(runtime.config, 'workspace', None)
274+
)
275+
except ValueError as e:
276+
return json.dumps({"success": False, "error": str(e)})
277+
278+
prompt = f"delete file {safe_path}"
193279

194280
result = await orchestrator.create_plan(prompt)
195281
if not result.success:
@@ -234,7 +320,12 @@ def acp_execute_command(command: str, cwd: str = None) -> str:
234320
JSON string with command output
235321
"""
236322
async def _execute():
237-
prompt = f"run command: {command}"
323+
try:
324+
safe_cmd = _sanitize_command(command)
325+
except ValueError as e:
326+
return json.dumps({"success": False, "error": str(e)})
327+
328+
prompt = f"run command: {safe_cmd}"
238329

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

417-
if not path.exists():
508+
# SECURITY: Ensure resolved path stays within workspace
509+
resolved = path.resolve()
510+
ws_resolved = Path(runtime.config.workspace).resolve()
511+
if not str(resolved).startswith(str(ws_resolved) + os.sep) and resolved != ws_resolved:
512+
return json.dumps({"error": f"Path escapes workspace: {filepath}"})
513+
514+
if not resolved.exists():
418515
return json.dumps({"error": f"File not found: {filepath}"})
419516

420-
content = path.read_text()
517+
content = resolved.read_text()
421518

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

454-
if not path.exists():
551+
# SECURITY: Ensure resolved path stays within workspace
552+
resolved = path.resolve()
553+
ws_resolved = Path(runtime.config.workspace).resolve()
554+
if not str(resolved).startswith(str(ws_resolved) + os.sep) and resolved != ws_resolved:
555+
return json.dumps({"error": f"Directory escapes workspace: {directory}"})
556+
557+
if not resolved.exists():
455558
return json.dumps({"error": f"Directory not found: {directory}"})
456559

457560
files = []

0 commit comments

Comments
 (0)