You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
An in-depth review of src/praisonai-agents/praisonaiagents found three gaps, each reproduced locally against the current main. They are ranked by impact. Docs, tests, coverage and file size are out of scope.
Permission DENY rules for shell commands are bypassed by trivial wrappers (security, fail-open).
The streaming tool loop silently drops chained tool calls and mis-assembles parallel calls (correctness).
Tool-output truncation for dict results never bounds the total size, so a single tool result can overflow the context (robustness/performance).
1. Shell DENY rules are bypassed by wrappers and path prefixes
Where:permissions/manager.py (PermissionManager.check, ~L322, and command decomposition ~L488-591) and permissions/command_parser.py.
Only the PowerShell/cmd wrappers are unwrapped. POSIX wrappers are not. has_unresolvable_expansion already escalates $VAR / ${IFS} to ASK, but the same fail-open class remains for the forms below. Unparseable input also returns None and falls through to the broad ALLOW.
Repro (rules bash:* ALLOW at priority 0 and bash:rm *DENY at priority 10):
An agent (or a prompt-injected agent) that has been denied rm can run it by prefixing sudo, env, /bin/, bash -c, and so on. Further untested forms reported by the reviewer: nohup, nice, command, exec, busybox, eval, { ...; }, if ...; then ...; fi, and command-position $(...) / backticks.
Suggested fix (keep it in the existing command parser; no new API surface):
And in PermissionManager.check: treat a shlex parse failure, and $(...) / backticks in command position, as ASK (the same treatment has_unresolvable_expansion already gives $VAR) instead of returning None.
Where:llm/llm.py, get_response_stream (~L5028-5045) and _process_tool_calls_from_stream (~L7586-7604). The accumulator is shared by the sync stream path and the async stream path.
2a. Follow-up turn is a single non-streaming call whose tool calls are discarded
# llm/llm.py ~L5028follow_up_response=self._completion_with_retry(
**self._build_completion_params(messages=messages, tools=formatted_tools,
temperature=temperature, stream=False, **kwargs))
iffollow_up_responseandfollow_up_response.choices:
follow_up_content=follow_up_response.choices[0].message.contentiffollow_up_content:
yieldfollow_up_content# tool_calls on this message are never looked at
The follow-up still advertises tools=formatted_tools. If the model answers it with another tool_calls (any multi-step task), content is None, the second tool is never executed, nothing more is yielded, and the user gets no final answer. The sync and async non-streaming loops iterate until the model stops calling tools; the stream loop does not.
Reviewer's mock-based repro (stream chunk requesting step1, then a follow-up requesting step2):
yielded: ['Let me check. ']
tools executed: ['step1']
completion calls (stream flags): [True, False] # step2 never ran
Also, lines ~4664-5286 contain no token-usage tracking, so streamed turns are absent from cost/usage accounting.
2b. Accumulator corrupts parallel calls and can raise IndexError
fromtypesimportSimpleNamespaceasNfrompraisonaiagents.llm.llmimportLLMl=LLM.__new__(LLM)
d=lambdai, id, n, a: N(tool_calls=[N(index=i, id=id, function=N(name=n, arguments=a))])
tc= []
forxin [d(0, "a", "get_time", '{"c":"P"}'), d(0, "b", "get_time", '{"tz":"U"}')]:
l._process_tool_calls_from_stream(x, tc)
print(tc)
# [{'id': 'a', ..., 'arguments': '{"c":"P"}{"tz":"U"}'}] <- two calls merged, invalid JSONl._process_tool_calls_from_stream(d(1, "z", "f", "{}"), [])
# IndexError: list index out of range
Caveat: this is simulated chunks, not a captured provider stream. Providers that reuse index=0 for distinct parallel calls are the ones affected. The IndexError case is deterministic whenever the first-seen index is greater than the current list length. The id is also never updated if it arrives after the first chunk.
Suggested fix:
def_process_tool_calls_from_stream(self, delta, tool_calls):
fortcingetattr(delta, "tool_calls", None) or []:
# key by index, but start a new slot when a *different* non-empty id appears at a used indexidx=tc.indexwhilelen(tool_calls) <=idx:
tool_calls.append({"id": None, "type": "function",
"function": {"name": "", "arguments": ""}})
slot=tool_calls[idx]
iftc.idandslot["id"] andtc.id!=slot["id"]:
tool_calls.append({"id": tc.id, "type": "function",
"function": {"name": "", "arguments": ""}})
slot=tool_calls[-1]
iftc.idandnotslot["id"]:
slot["id"] =tc.idiftc.functionandtc.function.name:
slot["function"]["name"] =tc.function.nameiftc.functionandtc.function.arguments:
slot["function"]["arguments"] +=tc.function.argumentsreturntool_calls
For 2a, replace the single stream=False follow-up with a loop: stream the follow-up, accumulate with the fixed accumulator, execute any tool calls, and repeat until the model returns plain content. Reuse the same tool-execution block as the first turn, and call the existing _track_token_usage / _extract_token_usage helpers used by the non-streaming loops.
3. Dict tool results are never bounded in total size
Where:agent/tool_execution.py (~L1695-1706, post-truncation block) and _truncate_dict_fields (~L2129-2172).
When a dict result is over the limit, the combined truncated string is discarded and _truncate_dict_fields is called instead. That helper only shortens an individual string field when that field is longer than max_field_chars (the same 16,000-char limit). Many fields just under the limit pass through untouched, so the total is unbounded.
Repro:
frompraisonaiagents.agent.tool_executionimportToolExecutionMixinasTclassX(T):
context_manager=Nonetool_output_limit=16000res= {"results": [{"content": "a"*15990} for_inrange(50)]}
out=X()._truncate_dict_fields(res, "search", 16000)
print(len(str(res)), len(str(out)))
# 800363 800363 <- "truncated" output is identical, ~200k tokens against a 16k-char limit
A search/crawl tool returning many medium-sized snippets therefore bypasses the limit and overflows the context window. It also means the _artifact_ref spill path (#2792) is only used when a single field is oversized.
Suggested fix: after the per-field pass, enforce a total budget. If len(str(result)) > limit, spill the full serialized result to the tool-output store and return the already-built truncated head/tail string, together with the artifact reference, rather than the barely-changed dict.
Lower-severity items found during the review but not included above, for follow-up if wanted: RateLimiter.acquire_tokens() spins forever when the request exceeds the bucket cap (llm/rate_limiter.py; currently has no in-tree caller), DefaultSessionStore._cache has no eviction (session/store.py:652), urllib.request.urlopen without a timeout in agent/context_agent.py:1780 and :2353, and _parse_retry_delay ignoring units (llm/llm.py:849-882).
Summary
An in-depth review of
src/praisonai-agents/praisonaiagentsfound three gaps, each reproduced locally against the currentmain. They are ranked by impact. Docs, tests, coverage and file size are out of scope.DENYrules for shell commands are bypassed by trivial wrappers (security, fail-open).1. Shell
DENYrules are bypassed by wrappers and path prefixesWhere:
permissions/manager.py(PermissionManager.check, ~L322, and command decomposition ~L488-591) andpermissions/command_parser.py.Only the PowerShell/cmd wrappers are unwrapped. POSIX wrappers are not.
has_unresolvable_expansionalready escalates$VAR/${IFS}toASK, but the same fail-open class remains for the forms below. Unparseable input also returnsNoneand falls through to the broadALLOW.Repro (rules
bash:* ALLOWat priority 0 andbash:rm *DENYat priority 10):Observed output:
An agent (or a prompt-injected agent) that has been denied
rmcan run it by prefixingsudo,env,/bin/,bash -c, and so on. Further untested forms reported by the reviewer:nohup,nice,command,exec,busybox,eval,{ ...; },if ...; then ...; fi, and command-position$(...)/ backticks.Suggested fix (keep it in the existing command parser; no new API surface):
And in
PermissionManager.check: treat ashlexparse failure, and$(...)/ backticks in command position, asASK(the same treatmenthas_unresolvable_expansionalready gives$VAR) instead of returningNone.2. Streaming tool loop drops chained tool calls and mis-assembles parallel calls
Where:
llm/llm.py,get_response_stream(~L5028-5045) and_process_tool_calls_from_stream(~L7586-7604). The accumulator is shared by the sync stream path and the async stream path.2a. Follow-up turn is a single non-streaming call whose tool calls are discarded
The follow-up still advertises
tools=formatted_tools. If the model answers it with anothertool_calls(any multi-step task),contentisNone, the second tool is never executed, nothing more is yielded, and the user gets no final answer. The sync and async non-streaming loops iterate until the model stops calling tools; the stream loop does not.Reviewer's mock-based repro (stream chunk requesting
step1, then a follow-up requestingstep2):Also, lines ~4664-5286 contain no token-usage tracking, so streamed turns are absent from cost/usage accounting.
2b. Accumulator corrupts parallel calls and can raise
IndexErrorRepro I ran:
Caveat: this is simulated chunks, not a captured provider stream. Providers that reuse
index=0for distinct parallel calls are the ones affected. TheIndexErrorcase is deterministic whenever the first-seen index is greater than the current list length. Theidis also never updated if it arrives after the first chunk.Suggested fix:
For 2a, replace the single
stream=Falsefollow-up with a loop: stream the follow-up, accumulate with the fixed accumulator, execute any tool calls, and repeat until the model returns plain content. Reuse the same tool-execution block as the first turn, and call the existing_track_token_usage/_extract_token_usagehelpers used by the non-streaming loops.3. Dict tool results are never bounded in total size
Where:
agent/tool_execution.py(~L1695-1706, post-truncation block) and_truncate_dict_fields(~L2129-2172).When a dict result is over the limit, the combined
truncatedstring is discarded and_truncate_dict_fieldsis called instead. That helper only shortens an individual string field when that field is longer thanmax_field_chars(the same 16,000-char limit). Many fields just under the limit pass through untouched, so the total is unbounded.Repro:
A search/crawl tool returning many medium-sized snippets therefore bypasses the limit and overflows the context window. It also means the
_artifact_refspill path (#2792) is only used when a single field is oversized.Suggested fix: after the per-field pass, enforce a total budget. If
len(str(result)) > limit, spill the full serialized result to the tool-output store and return the already-builttruncatedhead/tail string, together with the artifact reference, rather than the barely-changed dict.Notes for triage
RateLimiter.acquire_tokens()spins forever when the request exceeds the bucket cap (llm/rate_limiter.py; currently has no in-tree caller),DefaultSessionStore._cachehas no eviction (session/store.py:652),urllib.request.urlopenwithout a timeout inagent/context_agent.py:1780and:2353, and_parse_retry_delayignoring units (llm/llm.py:849-882).🤖 Generated with Claude Code
https://claude.ai/code/session_015Eh9NTQ8MAwxDxpsdSkHig