Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a2b5d3b371 | |||
| 352e61f526 | |||
| 55bea49585 | |||
| 79a5bdea5a | |||
| 0bad313aa3 | |||
| 1196c726f2 | |||
| 655cd7ebc2 |
+3
-10
@@ -64,6 +64,7 @@ Changed `wf10_batch.robot` to be less likely to create files, and
|
||||
when separate `provider`/`model` keys are absent. Added validation to reject malformed
|
||||
combined values with empty provider or model halves.
|
||||
|
||||
- **Fixed plan tree reporting zero decision nodes after strategize** (#10813): The `plan tree` command showed no ``decision_id`` fields even though planning completed successfully. Root cause: the PlanExecutor's ``run_strategize()`` method produced strategy decisions via the StrategyActor but never persisted them as domain ``Decision`` objects through the DecisionService wiring. Added ``decision_service`` parameter to the PlanExecutor constructor, wired it from the CLI dependency-injection container in ``_get_plan_executor()``, and added ``_persist_strategy_decisions()`` that converts each strategy decision into a domain Decision with correct type mapping (prompt_definition, strategy_choice, subplan_spawn) so they appear in plan tree output.
|
||||
- **`task-implementor` posts work-started notification comments** (#11031): Both
|
||||
the `issue_impl` and `pr_fix` procedures now post an informational "work
|
||||
started" comment to the Forgejo issue/PR before beginning implementation.
|
||||
@@ -231,6 +232,8 @@ Changed `wf10_batch.robot` to be less likely to create files, and
|
||||
`hot_context_hash`, `hot_context_ref`, `actor_state_ref`, and `relevant_resources`
|
||||
are all populated for Strategize-phase decisions.
|
||||
|
||||
- **`auto_debug` node functions return partial state updates instead of mutating state** (#10494, #10496): The `_analyze_error()`, `_generate_fix()`, `_validate_fix()`, and `_finalize()` methods in `src/cleveragents/agents/graphs/auto_debug.py` now return new `dict[str, Any]` objects containing only the keys they update rather than mutating the input state in-place or returning full state objects. This respects the LangGraph node contract (all `StateGraph` node functions are passed the current state and must return a partial dict representing only their incremental updates). Callers rely on LangGraph's built-in state delta-merge logic to combine these partial updates across successive nodes.
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed stale `AUTO-BUG-POOL` tracking prefix references in automation-tracking.md documentation and agent-system-specification.md spec document, replaced with correct `AUTO-BUG-SUP` prefix used by the bug-hunt-pool-supervisor agent (#7875).
|
||||
@@ -272,16 +275,6 @@ Changed `wf10_batch.robot` to be less likely to create files, and
|
||||
versions (<3.13.4) cannot be installed even if upstream transitive dependencies have loose
|
||||
version constraints.
|
||||
|
||||
- **Path containment checks replaced `startswith` with semantic path comparison** (#7478, #7801):
|
||||
Replaced insecure string-prefix path containment (`str.startswith(root + "/")`) with
|
||||
semantics-aware ``os.path.relpath`` checks in ``tool/path_mapper.py`` and
|
||||
``application/services/llm_actors.py``. The previous ``startswith`` approach was
|
||||
vulnerable to sibling-directory prefix-collision attacks where a sandbox root of
|
||||
``/tmp/sandbox`` would incorrectly allow access to ``/tmp/sandboxmalicious/path``.
|
||||
All path containment checks now use ``os.path.relpath`` or
|
||||
``Path.is_relative_to()`` for safe, semantic containment verification (as mandated
|
||||
by the security specification).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Error suppression removed from `reactive_registry_adapter.py`** (#9060): Removed two `try...except Exception:` blocks in `register_registry_agents()` that silently suppressed errors, violating the CONTRIBUTING.md fail-fast policy. Exceptions from `actor_registry.list_actors()` and the route bridge refresh now propagate to the caller instead of being swallowed. Added Behave scenarios verifying RuntimeError, AttributeError, and TypeError propagation.
|
||||
|
||||
+2
-1
@@ -8,6 +8,7 @@
|
||||
* Jeffrey Phillips Freeman <the@jeffreyfreeman.me>
|
||||
* Luis Mendes <luis.p.mendes@gmail.com>
|
||||
* Rui Hu <rui.hu@cleverthis.com>
|
||||
* HAL 9000 <hal9000@cleverthis.com> has contributed fix for #10813 — wiring DecisionService into PlanExecutor for strategy decision persistence during strategize.
|
||||
|
||||
# Details
|
||||
|
||||
@@ -44,6 +45,6 @@ Below are some of the specific details of various contributions.
|
||||
* HAL 9000 has contributed database resource types (PostgreSQL, SQLite) with transaction-based sandbox strategy: implemented ``DatabaseResourceHandler`` providing full CRUD operations (`read`, `write`, `delete`, `list_children`) and connection validation with automatic credential masking for PostgreSQL and SQLite backends. Includes ``TransactionSandbox`` infrastructure wired into ``SandboxFactory``, BDD test coverage in ``features/database_resources.feature``, and Robot Framework integration tests in ``robot/database_resources.robot`` (PR #10591 / issue #8608, Epic #8568).
|
||||
* HAL 9000 has contributed the agents plan rollback command (PR #8674 / issue #8557): implemented checkpoint-based plan state restoration with the `agents plan rollback <plan-id> [<checkpoint-id>]` CLI command as part of Epic #8493, enabling plans to be restored to previous checkpoints, discarding post-checkpoint decisions, and resuming execution from the rolled-back state. Supported by `--yes/-y`, `--to-checkpoint`, and `--format/-f` flags. Includes comprehensive BDD test coverage (>= 97%) for rollback, decision discarding, and plan resume functionality.
|
||||
* HAL 9000 has contributed the PyYAML security upgrade (PR #11012 / issue #9055): added `pyyaml>=6.0.3` dependency constraint to address known YAML parsing vulnerabilities.
|
||||
* HAL 9000 has contributed the DecisionService wiring for PlanExecutor strategize persistence fix (#10813): added decision_service to the PlanExecutor constructor and wired it from the CLI dependency-injection container in `_get_plan_executor()`, plus implemented `_persist_strategy_decisions()` to persist strategy decisions as domain `Decision` objects.
|
||||
* HAL 9000 has contributed the A2A module rename standardization BDD tests (PR #10583 / issue #8615): comprehensive Behave test suite validating that all 22 A2A symbols are properly exported from `cleveragents.a2a`, no legacy ACP references remain in the module source, and documentation uses correct A2A naming conventions — fixing inline imports, unused behave symbols, cross-scenario context dependencies, and missing type annotations.
|
||||
* HAL 9000 has contributed the `ActorSelectionOverlay._render` → `_refresh_display` rename fix (PR #11176 / issue #11039, Epic #8174): renamed `_render()` method to `_refresh_display()` to avoid shadowing Textual's `Widget._render()`, fixing a crash in textual >=1.0 where `get_content_height()` would receive `None` and raise `AttributeError: 'NoneType' object has no attribute 'get_height'`.
|
||||
* HAL 9000 has contributed the path containment security hardening fix (PR #7801 / issue #7478): replaced insecure ``str.startswith(root + "/")`` string-prefix path containment checks with semantic ``os.path.relpath`` comparisons in ``tool/path_mapper.py`` (_is_under) and ``application/services/llm_actors.py`` (_write_to_sandbox), eliminating the sibling-directory prefix-collision path traversal bypass vulnerability.
|
||||
@@ -1,48 +0,0 @@
|
||||
Feature: Path containment startswith bypass prevention (issue #7478 / PR #7801)
|
||||
AS a security engineer
|
||||
I WANT path containment checks to use semantic comparison instead of string prefix matching
|
||||
SO THAT sibling-directory prefix-collision attacks cannot bypass sandbox isolation
|
||||
|
||||
Background:
|
||||
Given a temporary sandbox directory "/tmp/sandbox"
|
||||
And a file "safe.txt" with content "safe content"
|
||||
|
||||
# ---- PathMapper _is_under prefix collision prevention (#7478) ----
|
||||
|
||||
@tdd_issue @tdd_issue_7478
|
||||
Scenario: PathMapper rejects sibling-prefix path traversal via relpath containment
|
||||
Given I have a PathMapper with host_root "/tmp/sandbox" and container_root "/workspace"
|
||||
And a sibling directory with a name that is a prefix of the sandbox root
|
||||
When I check whether host path is safe from prefix collision
|
||||
Then the prefix collision check should return ``False``
|
||||
|
||||
@tdd_issue @tdd_issue_7478
|
||||
Scenario: PathMapper correctly identifies legitimate child paths
|
||||
Given I have a PathMapper with host_root "/tmp/sandbox" and container_root "/workspace"
|
||||
When I check whether "/tmp/sandbox/src/main.py" is a host path
|
||||
Then the host containment result should be true
|
||||
|
||||
@tdd_issue @tdd_issue_7478
|
||||
Scenario: PathMapper correctly identifies root equality
|
||||
Given I have a PathMapper with host_root "/tmp/sandbox" and container_root "/workspace"
|
||||
When I check whether "/tmp/sandbox" is a host path
|
||||
Then the host containment result should be true
|
||||
|
||||
@tdd_issue @tdd_issue_7478
|
||||
Scenario: PathMapper rejects sibling-prefix escape path
|
||||
Given I have a PathMapper with host_root "/tmp/sandbox" and container_root "/workspace"
|
||||
And a file "escape-evil.txt" with content "malicious" at "/tmp/sandbox-escape/escape-evil.txt"
|
||||
When I check whether "/tmp/sandbox-escape/escape-evil.txt" is a host path
|
||||
Then the host containment result should be false
|
||||
|
||||
@tdd_issue @tdd_issue_7478
|
||||
Scenario: PathMapper maps root path exactly (no relative component)
|
||||
Given I have a PathMapper with host_root "/tmp/sandbox" and container_root "/workspace"
|
||||
When I map the host path "/tmp/sandbox" to container
|
||||
Then the mapped path should be "/workspace"
|
||||
|
||||
@tdd_issue @tdd_issue_7478
|
||||
Scenario: PathMapper maps a child path correctly through relpath
|
||||
Given I have a PathMapper with host_root "/tmp/sandbox" and container_root "/workspace"
|
||||
When I map the host path "/tmp/sandbox/src/main.py" to container
|
||||
Then the mapped path should be "/workspace/src/main.py"
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
@@ -71,18 +70,6 @@ def step_have_container_exec_module(context: Any) -> None:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given('a temporary sandbox directory "{path}"')
|
||||
def step_given_named_sandbox(context: Any, path: str) -> None:
|
||||
"""Create the named directory as a temporary sandbox root for path tests."""
|
||||
os.makedirs(path, exist_ok=True)
|
||||
if not hasattr(context, "_cleanup_handlers"):
|
||||
context._cleanup_handlers = []
|
||||
context._cleanup_handlers.append(
|
||||
lambda p=path: shutil.rmtree(p, ignore_errors=True)
|
||||
)
|
||||
context.sandbox_dir = path
|
||||
|
||||
|
||||
@given(
|
||||
'I have a PathMapper with host_root "{host_root}" and container_root "{container_root}"'
|
||||
)
|
||||
@@ -95,12 +82,6 @@ def step_map_host_to_container(context: Any, path: str) -> None:
|
||||
context.mapped_path = context.path_mapper.host_to_container(path)
|
||||
|
||||
|
||||
@when('I map the host path "{path}" to container')
|
||||
def step_map_the_host_to_container(context: Any, path: str) -> None:
|
||||
"""Alias for step_map_host_to_container (used in path containment security tests)."""
|
||||
context.mapped_path = context.path_mapper.host_to_container(path)
|
||||
|
||||
|
||||
@when('I map container path "{path}" to host')
|
||||
def step_map_container_to_host(context: Any, path: str) -> None:
|
||||
context.mapped_path = context.path_mapper.container_to_host(path)
|
||||
@@ -113,14 +94,6 @@ def step_check_container_path(context: Any, expected: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
@then('the mapped path should be "{expected}"')
|
||||
def step_check_mapped_path(context: Any, expected: str) -> None:
|
||||
"""Assert that the most recently mapped path equals expected."""
|
||||
assert context.mapped_path == expected, (
|
||||
f"Expected {expected!r}, got {context.mapped_path!r}"
|
||||
)
|
||||
|
||||
|
||||
@then('the host path should be "{expected}"')
|
||||
def step_check_host_path(context: Any, expected: str) -> None:
|
||||
assert context.mapped_path == expected, (
|
||||
@@ -154,116 +127,6 @@ def step_is_not_container_path(context: Any, path: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PathMapper prefix-collision security tests (#7478)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a sibling directory with a name that is a prefix of the sandbox root")
|
||||
def step_sibling_prefix_dir(context: Any) -> None:
|
||||
"""Create a sibling directory whose name starts with the sandbox root name.
|
||||
|
||||
For example, if the sandbox host_root is ``/tmp/sandbox``, this creates
|
||||
``/tmp/sandbox-escape``. This exercises the startswith prefix-collision
|
||||
path traversal bypass (issue #7478).
|
||||
"""
|
||||
path_mapper = getattr(context, "path_mapper", None)
|
||||
assert path_mapper is not None, "PathMapper not initialized in context"
|
||||
host_root: str = path_mapper.host_root
|
||||
root_parent = str(Path(host_root).parent)
|
||||
sibling_name = Path(host_root).name + "-escape"
|
||||
sibling_path = Path(root_parent) / sibling_name
|
||||
sibling_path.mkdir(parents=True, exist_ok=True)
|
||||
context._cleanup_handlers.append(
|
||||
lambda: shutil.rmtree(str(sibling_path), ignore_errors=True)
|
||||
)
|
||||
|
||||
|
||||
@when("I check whether host path is safe from prefix collision")
|
||||
def step_check_prefix_collision(context: Any) -> None:
|
||||
"""Check whether a sibling-prefix path incorrectly passes containment."""
|
||||
path_mapper = getattr(context, "path_mapper", None)
|
||||
assert path_mapper is not None, "PathMapper not initialized in context"
|
||||
host_root: str = path_mapper.host_root
|
||||
root_parent = str(Path(host_root).parent)
|
||||
root_name = Path(host_root).name
|
||||
escape_root = f"{root_name}-escape"
|
||||
escape_path = os.path.join(root_parent, escape_root, "evil.txt")
|
||||
context.prefix_collision_result = path_mapper.is_host_path(escape_path)
|
||||
# Store the escape path for use in the error message
|
||||
context.escape_path = escape_path
|
||||
|
||||
|
||||
@then("the prefix collision check should return ``False``")
|
||||
def step_prefix_collision_rejected(context: Any) -> None:
|
||||
result: bool = getattr(context, "prefix_collision_result", None)
|
||||
assert result is False, (
|
||||
f"Expected is_host_path({getattr(context, 'escape_path', 'unknown')!r}) to be False "
|
||||
f"but got True — prefix collision bypass not prevented!"
|
||||
)
|
||||
|
||||
|
||||
@then('"{path}" should result be false for host path containment')
|
||||
def step_host_path_result_false(context: Any, path: str) -> None:
|
||||
"""Generalized step to assert is_host_path returns False for a given path."""
|
||||
result = context.path_mapper.is_host_path(path)
|
||||
assert result is False, (
|
||||
f"Expected is_host_path({path!r}) to be False but got {result}"
|
||||
)
|
||||
|
||||
|
||||
@then('"{path}" should result be true for host path containment')
|
||||
def step_host_path_result_true(context: Any, path: str) -> None:
|
||||
"""Generalized step to assert is_host_path returns True for a given path."""
|
||||
result = context.path_mapper.is_host_path(path)
|
||||
assert result is True, (
|
||||
f"Expected is_host_path({path!r}) to be True but got {result}"
|
||||
)
|
||||
|
||||
|
||||
@given(
|
||||
'a file "{name}" with content "{content}" at "{absolute_path}"'
|
||||
)
|
||||
def step_create_file_at_absolute_path(
|
||||
context: Any, name: str, content: str, absolute_path: str
|
||||
) -> None:
|
||||
"""Create a file at an arbitrary absolute path (used for sibling-prefix tests)."""
|
||||
abs_path = Path(absolute_path)
|
||||
abs_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
abs_path.write_text(content)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Generic result assertions (for prefix collision test scenarios)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I check whether "{path}" is a host path')
|
||||
def step_check_host_path(context: Any, path: str) -> None:
|
||||
"""Store the result of is_host_path for later assertion."""
|
||||
path_mapper = getattr(context, "path_mapper", None)
|
||||
assert path_mapper is not None, "PathMapper not initialized in context"
|
||||
context._host_path_result = path_mapper.is_host_path(path)
|
||||
|
||||
|
||||
@then("the host containment result should be true")
|
||||
def step_result_is_true(context: Any) -> None:
|
||||
"""Assert that the most recent host path containment check returned True."""
|
||||
result: bool = getattr(context, "_host_path_result", False)
|
||||
assert result is True, (
|
||||
f"Expected host path check to return True but got {result}"
|
||||
)
|
||||
|
||||
|
||||
@then("the host containment result should be false")
|
||||
def step_result_is_false(context: Any) -> None:
|
||||
"""Assert that the most recent host path containment check returned False."""
|
||||
result: bool = getattr(context, "_host_path_result", True)
|
||||
assert result is False, (
|
||||
f"Expected host path check to return False but got {result}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ContainerConfig
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Step definitions for tdd_auto_debug_state_mutation.feature.
|
||||
|
||||
These steps verify that AutoDebug node functions return new state dicts
|
||||
instead of mutating the input state in-place, respecting the LangGraph
|
||||
node contract.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
from behave import given, then, when
|
||||
|
||||
from cleveragents.agents.graphs.auto_debug import AutoDebugAgent, AutoDebugState
|
||||
|
||||
|
||||
class _MockResponse:
|
||||
"""Minimal response object with a content attribute."""
|
||||
|
||||
def __init__(self, content: str) -> None:
|
||||
self.content = content
|
||||
|
||||
|
||||
class _MockLLM:
|
||||
"""Stub LLM that returns a fixed content string."""
|
||||
|
||||
def __init__(self, content: str = "Mock LLM response") -> None:
|
||||
self._content = content
|
||||
|
||||
def invoke(self, messages: Any) -> _MockResponse:
|
||||
return _MockResponse(self._content)
|
||||
|
||||
|
||||
class _InvalidValidationLLM:
|
||||
"""Stub LLM that returns an invalid validation response."""
|
||||
|
||||
def invoke(self, messages: Any) -> _MockResponse:
|
||||
return _MockResponse(
|
||||
json.dumps(
|
||||
{
|
||||
"is_valid": False,
|
||||
"reasoning": "Fix failed",
|
||||
"issues": ["error persists"],
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _base_state(**overrides: Any) -> AutoDebugState:
|
||||
"""Create a minimal valid AutoDebugState with optional overrides."""
|
||||
state: AutoDebugState = {
|
||||
"messages": [],
|
||||
"context": {},
|
||||
"result": None,
|
||||
"error": None,
|
||||
"metadata": {},
|
||||
"error_message": "NameError: name 'x' is not defined",
|
||||
"code_context": "print(x)",
|
||||
"attempted_fixes": [],
|
||||
"current_fix": {},
|
||||
"fix_validated": False,
|
||||
}
|
||||
state.update(overrides) # type: ignore[typeddict-item]
|
||||
return state
|
||||
|
||||
|
||||
@given("the auto debug state mutation module is imported")
|
||||
def step_module_imported(context: Any) -> None:
|
||||
"""Verify the auto_debug module is importable."""
|
||||
assert AutoDebugAgent is not None
|
||||
assert AutoDebugState is not None
|
||||
|
||||
|
||||
@given("an auto debug agent for mutation testing")
|
||||
def step_create_agent(context: Any) -> None:
|
||||
"""Create an AutoDebugAgent with a mock LLM."""
|
||||
context.mutation_agent = AutoDebugAgent(llm=_MockLLM(), max_fix_attempts=3)
|
||||
|
||||
|
||||
@given("an initial auto debug state for mutation testing")
|
||||
def step_initial_state(context: Any) -> None:
|
||||
"""Create an initial state with empty messages list."""
|
||||
context.mutation_input_state = _base_state(messages=[])
|
||||
context.mutation_original_messages_len = len(
|
||||
context.mutation_input_state["messages"]
|
||||
)
|
||||
|
||||
|
||||
@given("a state with an error analysis for mutation testing")
|
||||
def step_state_with_analysis(context: Any) -> None:
|
||||
"""Create a state with an error analysis message."""
|
||||
context.mutation_input_state = _base_state(
|
||||
messages=[
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Detected a NameError",
|
||||
"type": "error_analysis",
|
||||
}
|
||||
],
|
||||
current_fix={},
|
||||
)
|
||||
context.mutation_original_current_fix = deepcopy(
|
||||
context.mutation_input_state["current_fix"]
|
||||
)
|
||||
|
||||
|
||||
@given("a state with a current fix for mutation testing")
|
||||
def step_state_with_current_fix(context: Any) -> None:
|
||||
"""Create a state with a current fix."""
|
||||
context.mutation_agent = AutoDebugAgent(llm=_MockLLM(), max_fix_attempts=3)
|
||||
context.mutation_input_state = _base_state(
|
||||
current_fix={
|
||||
"description": "Proposed fix",
|
||||
"code": "x = 42\nprint(x)",
|
||||
"files_to_modify": ["main.py"],
|
||||
},
|
||||
fix_validated=False,
|
||||
)
|
||||
context.mutation_original_fix_validated = context.mutation_input_state[
|
||||
"fix_validated"
|
||||
]
|
||||
|
||||
|
||||
@given("a state with a current fix and invalid validation for mutation testing")
|
||||
def step_state_with_invalid_validation(context: Any) -> None:
|
||||
"""Create a state with a current fix that will fail validation."""
|
||||
context.mutation_agent = AutoDebugAgent(
|
||||
llm=_InvalidValidationLLM(), max_fix_attempts=3
|
||||
)
|
||||
context.mutation_input_state = _base_state(
|
||||
current_fix={
|
||||
"description": "Proposed fix",
|
||||
"code": "x = 42\nprint(x)",
|
||||
"files_to_modify": ["main.py"],
|
||||
},
|
||||
attempted_fixes=[],
|
||||
fix_validated=False,
|
||||
)
|
||||
context.mutation_original_attempted_fixes_len = len(
|
||||
context.mutation_input_state["attempted_fixes"]
|
||||
)
|
||||
|
||||
|
||||
@given("a state ready for finalization for mutation testing")
|
||||
def step_state_for_finalization(context: Any) -> None:
|
||||
"""Create a state ready for finalization."""
|
||||
context.mutation_agent = AutoDebugAgent(llm=_MockLLM(), max_fix_attempts=3)
|
||||
context.mutation_input_state = _base_state(
|
||||
fix_validated=True,
|
||||
current_fix={"description": "Final fix", "code": "# fixed"},
|
||||
result=None,
|
||||
)
|
||||
context.mutation_original_result = context.mutation_input_state["result"]
|
||||
|
||||
|
||||
@when("I call _analyze_error and capture the result")
|
||||
def step_call_analyze_error(context: Any) -> None:
|
||||
"""Call _analyze_error and capture both input and output."""
|
||||
context.mutation_result_state = context.mutation_agent._analyze_error(
|
||||
context.mutation_input_state
|
||||
)
|
||||
|
||||
|
||||
@when("I call _generate_fix and capture the result")
|
||||
def step_call_generate_fix(context: Any) -> None:
|
||||
"""Call _generate_fix and capture both input and output."""
|
||||
context.mutation_result_state = context.mutation_agent._generate_fix(
|
||||
context.mutation_input_state
|
||||
)
|
||||
|
||||
|
||||
@when("I call _validate_fix and capture the result")
|
||||
def step_call_validate_fix(context: Any) -> None:
|
||||
"""Call _validate_fix and capture both input and output."""
|
||||
context.mutation_result_state = context.mutation_agent._validate_fix(
|
||||
context.mutation_input_state
|
||||
)
|
||||
|
||||
|
||||
@when("I call _finalize and capture the result")
|
||||
def step_call_finalize(context: Any) -> None:
|
||||
"""Call _finalize and capture both input and output."""
|
||||
context.mutation_result_state = context.mutation_agent._finalize(
|
||||
context.mutation_input_state
|
||||
)
|
||||
|
||||
|
||||
@then("the returned state should be a different object from the input state")
|
||||
def step_different_object(context: Any) -> None:
|
||||
"""Verify the returned state is a new dict, not the same object."""
|
||||
assert context.mutation_result_state is not context.mutation_input_state, (
|
||||
"Node function returned the same state object (mutated in-place). "
|
||||
"LangGraph node functions must return a new dict."
|
||||
)
|
||||
|
||||
|
||||
@then("the original state messages list should be unchanged")
|
||||
def step_messages_unchanged(context: Any) -> None:
|
||||
"""Verify the original state's messages list was not mutated."""
|
||||
original_len = context.mutation_original_messages_len
|
||||
actual_len = len(context.mutation_input_state["messages"])
|
||||
assert actual_len == original_len, (
|
||||
f"Original state messages list was mutated: "
|
||||
f"expected {original_len} messages, got {actual_len}"
|
||||
)
|
||||
|
||||
|
||||
@then("the original state current_fix should be unchanged")
|
||||
def step_current_fix_unchanged(context: Any) -> None:
|
||||
"""Verify the original state's current_fix was not mutated."""
|
||||
original = context.mutation_original_current_fix
|
||||
actual = context.mutation_input_state["current_fix"]
|
||||
assert actual == original, (
|
||||
f"Original state current_fix was mutated: expected {original!r}, got {actual!r}"
|
||||
)
|
||||
|
||||
|
||||
@then("the original state fix_validated should be unchanged")
|
||||
def step_fix_validated_unchanged(context: Any) -> None:
|
||||
"""Verify the original state's fix_validated was not mutated."""
|
||||
original = context.mutation_original_fix_validated
|
||||
actual = context.mutation_input_state["fix_validated"]
|
||||
assert actual == original, (
|
||||
f"Original state fix_validated was mutated: "
|
||||
f"expected {original!r}, got {actual!r}"
|
||||
)
|
||||
|
||||
|
||||
@then("the original state attempted_fixes list should be unchanged")
|
||||
def step_attempted_fixes_unchanged(context: Any) -> None:
|
||||
"""Verify the original state's attempted_fixes list was not mutated."""
|
||||
original_len = context.mutation_original_attempted_fixes_len
|
||||
actual_len = len(context.mutation_input_state["attempted_fixes"])
|
||||
assert actual_len == original_len, (
|
||||
f"Original state attempted_fixes list was mutated: "
|
||||
f"expected {original_len} items, got {actual_len}"
|
||||
)
|
||||
|
||||
|
||||
@then("the original state result should be unchanged")
|
||||
def step_result_unchanged(context: Any) -> None:
|
||||
"""Verify the original state's result was not mutated."""
|
||||
original = context.mutation_original_result
|
||||
actual = context.mutation_input_state["result"]
|
||||
assert actual == original, (
|
||||
f"Original state result was mutated: expected {original!r}, got {actual!r}"
|
||||
)
|
||||
|
||||
|
||||
@then("the returned state must contain key {key_str}")
|
||||
def step_returned_state_has_key(context: Any, key_str: str) -> None:
|
||||
"""Verify the returned partial-state dict contains the expected key."""
|
||||
# Strip quotes if present (e.g. '"messages"' or "messages")
|
||||
key = key_str.strip("\"'")
|
||||
assert key in context.mutation_result_state, (
|
||||
f"Returned state missing expected key '{key}'. "
|
||||
f"Keys present: {list(context.mutation_result_state.keys())}"
|
||||
)
|
||||
@@ -0,0 +1,68 @@
|
||||
@tdd_issue @tdd_issue_10496
|
||||
Feature: AutoDebug node functions must not mutate state in-place
|
||||
As a LangGraph developer
|
||||
I want AutoDebug node functions to return new state dicts
|
||||
So that the LangGraph node contract is respected and state isolation is maintained
|
||||
|
||||
Background:
|
||||
Given the auto debug state mutation module is imported
|
||||
|
||||
Scenario: _analyze_error does not mutate the original state object
|
||||
Given an auto debug agent for mutation testing
|
||||
And an initial auto debug state for mutation testing
|
||||
When I call _analyze_error and capture the result
|
||||
Then the returned state should be a different object from the input state
|
||||
And the original state messages list should be unchanged
|
||||
|
||||
Scenario: _analyze_error returns expected keys in partial state dict
|
||||
Given an auto debug agent for mutation testing
|
||||
And an initial auto debug state for mutation testing
|
||||
When I call _analyze_error and capture the result
|
||||
Then the returned state must contain key "messages"
|
||||
|
||||
Scenario: _generate_fix does not mutate the original state object
|
||||
Given an auto debug agent for mutation testing
|
||||
And a state with an error analysis for mutation testing
|
||||
When I call _generate_fix and capture the result
|
||||
Then the returned state should be a different object from the input state
|
||||
And the original state current_fix should be unchanged
|
||||
|
||||
Scenario: _generate_fix returns expected keys in partial state dict
|
||||
Given an auto debug agent for mutation testing
|
||||
And a state with an error analysis for mutation testing
|
||||
When I call _generate_fix and capture the result
|
||||
Then the returned state must contain key "current_fix"
|
||||
|
||||
Scenario: _validate_fix does not mutate the original state object
|
||||
Given an auto debug agent for mutation testing
|
||||
And a state with a current fix for mutation testing
|
||||
When I call _validate_fix and capture the result
|
||||
Then the returned state should be a different object from the input state
|
||||
And the original state fix_validated should be unchanged
|
||||
|
||||
Scenario: _validate_fix (invalid) returns expected keys in partial state dict
|
||||
Given an auto debug agent for mutation testing
|
||||
And a state with a current fix and invalid validation for mutation testing
|
||||
When I call _validate_fix and capture the result
|
||||
Then the returned state must contain key "fix_validated"
|
||||
And the returned state must contain key "attempted_fixes"
|
||||
|
||||
Scenario: _validate_fix with invalid fix does not mutate attempted_fixes in-place
|
||||
Given an auto debug agent for mutation testing
|
||||
And a state with a current fix and invalid validation for mutation testing
|
||||
When I call _validate_fix and capture the result
|
||||
Then the returned state should be a different object from the input state
|
||||
And the original state attempted_fixes list should be unchanged
|
||||
|
||||
Scenario: _finalize does not mutate the original state object
|
||||
Given an auto debug agent for mutation testing
|
||||
And a state ready for finalization for mutation testing
|
||||
When I call _finalize and capture the result
|
||||
Then the returned state should be a different object from the input state
|
||||
And the original state result should be unchanged
|
||||
|
||||
Scenario: _finalize returns expected keys in partial state dict
|
||||
Given an auto debug agent for mutation testing
|
||||
And a state ready for finalization for mutation testing
|
||||
When I call _finalize and capture the result
|
||||
Then the returned state must contain key "result"
|
||||
@@ -129,7 +129,7 @@ class ActorRegistry:
|
||||
yaml_dict: dict[str, Any] = {
|
||||
"name": self._actor_name(provider, model),
|
||||
"type": "llm",
|
||||
"model": model,
|
||||
"model": f"{provider}/{model}",
|
||||
"description": (
|
||||
f"Built-in actor from provider registry ({provider}/{model})"
|
||||
),
|
||||
|
||||
@@ -89,7 +89,12 @@ class AutoDebugAgent:
|
||||
|
||||
return workflow
|
||||
|
||||
def _analyze_error(self, state: AutoDebugState) -> AutoDebugState:
|
||||
def _analyze_error(self, state: AutoDebugState) -> dict[str, Any]:
|
||||
"""Analyze the error message and return updated messages.
|
||||
|
||||
Returns a new partial state dict with the updated messages list,
|
||||
without mutating the input state.
|
||||
"""
|
||||
logger.info("Analyzing error message")
|
||||
|
||||
error_msg = state.get("error_message", "")
|
||||
@@ -129,17 +134,28 @@ Analyze this error and provide insights."""
|
||||
logger.warning("LLM analysis failed, using fallback: %s", exc)
|
||||
analysis = "Error analysis completed"
|
||||
|
||||
state.setdefault("messages", []).append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": analysis,
|
||||
"type": "error_analysis",
|
||||
}
|
||||
)
|
||||
new_message: dict[str, Any] = {
|
||||
"role": "assistant",
|
||||
"content": analysis,
|
||||
"type": "error_analysis",
|
||||
}
|
||||
# Unpack operator creates a new list (not in-place mutation), but inner
|
||||
# message dicts are shared by reference. This is safe because individual
|
||||
# message dicts are immutable-after-creation; LangGraph's state merging
|
||||
# replaces them rather than mutating dict contents.
|
||||
updated_messages: list[dict[str, Any]] = [
|
||||
*state.get("messages", []),
|
||||
new_message,
|
||||
]
|
||||
|
||||
return state
|
||||
return {"messages": updated_messages}
|
||||
|
||||
def _generate_fix(self, state: AutoDebugState) -> AutoDebugState:
|
||||
def _generate_fix(self, state: AutoDebugState) -> dict[str, Any]:
|
||||
"""Generate a fix suggestion and return updated current_fix.
|
||||
|
||||
Returns a new partial state dict with the updated current_fix,
|
||||
without mutating the input state.
|
||||
"""
|
||||
logger.info("Generating fix suggestion")
|
||||
|
||||
error_analysis = next(
|
||||
@@ -220,10 +236,21 @@ Generate fix attempt #{attempt_num}."""
|
||||
"files_to_modify": [],
|
||||
}
|
||||
|
||||
state["current_fix"] = fix_data
|
||||
return state
|
||||
return {"current_fix": fix_data}
|
||||
|
||||
def _validate_fix(self, state: AutoDebugState) -> AutoDebugState:
|
||||
def _validate_fix(self, state: AutoDebugState) -> dict[str, Any]:
|
||||
"""Validate the current fix and return updated validation state.
|
||||
|
||||
Returns a new partial state dict with the updated ``fix_validated``
|
||||
and (when invalid) ``attempted_fixes`` fields, without mutating the
|
||||
input state.
|
||||
|
||||
The returned dict has **asymmetric key coverage**: when the fix is
|
||||
invalid, both ``"fix_validated"`` and ``"attempted_fixes"`` are
|
||||
included; when valid, only ``"fix_validated"`` is returned. This is
|
||||
intentional — LangGraph merges partial dicts into state, so omitted
|
||||
keys are left unmodified rather than reset to defaults.
|
||||
"""
|
||||
logger.info("Validating fix")
|
||||
|
||||
current_fix = state.get("current_fix", {})
|
||||
@@ -275,17 +302,22 @@ Validate this fix."""
|
||||
for word in ["valid", "correct", "resolves", "fixes"]
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.warning("LLM validation failed, using fallback: %s", exc)
|
||||
is_valid = True
|
||||
|
||||
state["fix_validated"] = is_valid
|
||||
logger.warning(
|
||||
"LLM validation failed, treating fix as invalid: %s", exc
|
||||
)
|
||||
is_valid = False
|
||||
|
||||
if not is_valid:
|
||||
attempted_fixes = state.get("attempted_fixes", [])
|
||||
attempted_fixes.append(current_fix)
|
||||
state["attempted_fixes"] = attempted_fixes
|
||||
updated_attempted_fixes: list[dict[str, Any]] = [
|
||||
*state.get("attempted_fixes", []),
|
||||
current_fix,
|
||||
]
|
||||
return {
|
||||
"fix_validated": is_valid,
|
||||
"attempted_fixes": updated_attempted_fixes,
|
||||
}
|
||||
|
||||
return state
|
||||
return {"fix_validated": is_valid}
|
||||
|
||||
def _should_retry_fix(self, state: AutoDebugState) -> str:
|
||||
if not state.get("fix_validated", False):
|
||||
@@ -294,15 +326,21 @@ Validate this fix."""
|
||||
return "retry"
|
||||
return "done"
|
||||
|
||||
def _finalize(self, state: AutoDebugState) -> AutoDebugState:
|
||||
def _finalize(self, state: AutoDebugState) -> dict[str, Any]:
|
||||
"""Finalize the auto-debug results and return updated result.
|
||||
|
||||
Returns a new partial state dict with the result field populated,
|
||||
without mutating the input state.
|
||||
"""
|
||||
logger.info("Finalizing auto-debug results")
|
||||
|
||||
state["result"] = {
|
||||
"success": state.get("fix_validated", False),
|
||||
"fix": state.get("current_fix"),
|
||||
"attempts": len(state.get("attempted_fixes", [])),
|
||||
return {
|
||||
"result": {
|
||||
"success": state.get("fix_validated", False),
|
||||
"fix": state.get("current_fix"),
|
||||
"attempts": len(state.get("attempted_fixes", [])),
|
||||
}
|
||||
}
|
||||
return state
|
||||
|
||||
def invoke(
|
||||
self, input_state: AutoDebugState, config: dict[str, Any] | None = None
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
import json
|
||||
from pathlib import PurePath
|
||||
from typing import Any, Protocol
|
||||
|
||||
@@ -70,6 +71,72 @@ class ACMSExecutePhaseContextAssembler(ExecutePhaseContextAssembler):
|
||||
return ProjectContextPolicy().resolve_view("execute")
|
||||
return policy.resolve_view("execute")
|
||||
|
||||
def _resolve_hot_max_tokens(self, project_names: list[str]) -> int:
|
||||
"""Resolve the effective ``hot_max_tokens`` budget for *project_names*.
|
||||
|
||||
Looks up each project's :attr:`ContextConfig.hot_max_tokens` from its
|
||||
stored context configuration. Projects that do not have an explicit
|
||||
setting are excluded from the aggregation so they do not affect
|
||||
the computed value.
|
||||
|
||||
Returns the **maximum** of all explicitly-set project-level values,
|
||||
falling back to :attr:`_hot_max_tokens` (the caller-passed global
|
||||
default) when no project overrides are present.
|
||||
"""
|
||||
from typing import cast
|
||||
|
||||
candidates: list[int] = []
|
||||
for namespaced_name in project_names:
|
||||
row = None
|
||||
try:
|
||||
session = self._project_repository._session() # type: ignore[attr-defined]
|
||||
from cleveragents.infrastructure.database.models import (
|
||||
NamespacedProjectModel,
|
||||
)
|
||||
|
||||
row = (
|
||||
session.query(NamespacedProjectModel)
|
||||
.filter_by(namespaced_name=namespaced_name)
|
||||
.first()
|
||||
)
|
||||
session.close()
|
||||
except AttributeError:
|
||||
self._logger.warning(
|
||||
"hot_max_tokens_session_factory_missing",
|
||||
project_name=namespaced_name,
|
||||
)
|
||||
continue
|
||||
except Exception:
|
||||
self._logger.warning(
|
||||
"hot_max_tokens_lookup_failed",
|
||||
project_name=namespaced_name,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if row is not None and row.context_policy_json is not None:
|
||||
try:
|
||||
config_dict = json.loads(cast(str, row.context_policy_json))
|
||||
tokens = config_dict.get("hot_max_tokens")
|
||||
if tokens is not None and isinstance(tokens, int) and tokens > 0:
|
||||
candidates.append(tokens)
|
||||
except (ValueError, TypeError):
|
||||
self._logger.warning(
|
||||
"hot_max_tokens_parse_failed",
|
||||
project_name=namespaced_name,
|
||||
)
|
||||
|
||||
if candidates:
|
||||
effective = max(candidates)
|
||||
self._logger.info(
|
||||
"hot_max_tokens_resolved_from_projects",
|
||||
project_names=project_names,
|
||||
project_values=candidates,
|
||||
effective=effective,
|
||||
)
|
||||
return effective
|
||||
# No project overrides --- use the caller-passed global default.
|
||||
return self._hot_max_tokens
|
||||
|
||||
@staticmethod
|
||||
def _path_matches(path: str, include: list[str], exclude: list[str]) -> bool:
|
||||
"""Return whether *path* passes include/exclude path globs.
|
||||
@@ -226,14 +293,21 @@ class ACMSExecutePhaseContextAssembler(ExecutePhaseContextAssembler):
|
||||
)
|
||||
return None
|
||||
|
||||
budget = CoreContextBudget(max_tokens=self._hot_max_tokens, reserved_tokens=0)
|
||||
# Resolve effective hot_max_tokens: project-level overrides take precedence
|
||||
# over the global default. When multiple projects have explicit values,
|
||||
# use the maximum so all projects can contribute within their biggest budget.
|
||||
effective_hot_max_tokens = self._resolve_hot_max_tokens(project_names)
|
||||
|
||||
budget = CoreContextBudget(
|
||||
max_tokens=effective_hot_max_tokens, reserved_tokens=0
|
||||
)
|
||||
request = ContextRequest(
|
||||
query=(
|
||||
f"Execute-phase context for plan {plan.identity.plan_id} "
|
||||
f"({', '.join(project_names)})"
|
||||
),
|
||||
purpose="llm_execute_phase_prompt",
|
||||
max_tokens=self._hot_max_tokens,
|
||||
max_tokens=effective_hot_max_tokens,
|
||||
)
|
||||
payload = self._pipeline.assemble(
|
||||
plan_id=plan.identity.plan_id,
|
||||
|
||||
@@ -499,20 +499,8 @@ class LLMExecuteActor:
|
||||
path = match.group(1).strip()
|
||||
content = match.group(2)
|
||||
full_path = os.path.normpath(os.path.join(sandbox_root, path))
|
||||
# Path traversal guard: reject paths escaping sandbox.
|
||||
# Uses ``os.path.relpath`` for semantic containment checks
|
||||
# instead of string prefix matching (which is vulnerable
|
||||
# to sibling-directory prefix-collision attacks).
|
||||
# See issue #7478 — *startswith bypass* in path containment.
|
||||
try:
|
||||
rel = os.path.relpath(full_path, sandbox_root)
|
||||
except (ValueError, TypeError):
|
||||
logger.warning(
|
||||
"Rejected path traversal in LLM output",
|
||||
path=path,
|
||||
resolved=full_path,
|
||||
)
|
||||
continue
|
||||
rel = os.path.relpath(full_path, sandbox_root)
|
||||
# Path traversal guard: reject paths escaping sandbox
|
||||
if rel.startswith(".." + os.sep) or rel == "..":
|
||||
logger.warning(
|
||||
"Rejected path traversal in LLM output",
|
||||
|
||||
@@ -79,12 +79,16 @@ def validate_path(path_str: str, sandbox_root: str | None = None) -> Path:
|
||||
Raises ``ValueError`` when the resolved path escapes *sandbox_root*
|
||||
(defaults to the current working directory when not supplied).
|
||||
"""
|
||||
root = Path(sandbox_root).resolve() if sandbox_root else Path.cwd().resolve()
|
||||
root = Path(sandbox_root) if sandbox_root else Path.cwd()
|
||||
root = root.resolve()
|
||||
|
||||
target = (root / path_str).resolve()
|
||||
if not (target == root or target.is_relative_to(root)):
|
||||
try:
|
||||
target.relative_to(root)
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
f"Path traversal detected: '{path_str}' escapes sandbox root '{root}'"
|
||||
)
|
||||
f"Path traversal detected: '{path_str}' escapes sandbox root"
|
||||
) from exc
|
||||
return target
|
||||
|
||||
|
||||
|
||||
@@ -163,19 +163,18 @@ def _normalise(path: str) -> str:
|
||||
def _is_under(path: str, root: str) -> bool:
|
||||
"""Return ``True`` if *path* is equal to or a child of *root*.
|
||||
|
||||
Uses semantic path containment via ``posixpath.relpath`` instead of
|
||||
string prefix matching (``str.startswith``). String prefix matching
|
||||
Uses semantic path containment via posixpath.relpath instead of
|
||||
string prefix matching (str.startswith). String prefix matching
|
||||
is vulnerable to sibling-directory prefix-collision attacks where
|
||||
``/tmp/sandbox`` would incorrectly match ``/tmp/sandboxmalicious/file``.
|
||||
/tmp/sandbox would incorrectly match /tmp/sandboxmalicious/file.
|
||||
|
||||
See issue #7478 — *startswith bypass* in path containment checks.
|
||||
See issue #7478 — startswith bypass in path containment checks.
|
||||
"""
|
||||
if path == root:
|
||||
return True
|
||||
try:
|
||||
relative = posixpath.relpath(path, root)
|
||||
except (ValueError, TypeError):
|
||||
# Cross-drive paths on Windows or other edge cases where relpath fails
|
||||
return False
|
||||
return not relative.startswith(".." + posixpath.sep) and relative != ".."
|
||||
|
||||
|
||||
Reference in New Issue
Block a user