OpenHands / OpenHands/software-agent-sdk

[Bug]: HookExecutor converts exit-0 hooks with decision: null into execution failures

Open Beginner friendly
#4,766 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug hooks priority:low ready-for-dev
Dominant language
Python
Stars
1.1k
Forks
539
Avg merge
1d 19h
Merged PRs (30d)
137

Description

Is there an existing issue for the same bug?
  • I have searched existing issues and this is not a duplicate.
Bug Description

HookExecutor.execute converts a successfully completed command hook into a synthetic execution failure when its JSON stdout contains "decision": null.

For example, a hook that prints:

{"decision": null}

and exits with status 0 returns:

HookResult(
    success=False,
    exit_code=-1,
    error=(
        "Hook execution failed: "
        "'NoneType' object has no attribute 'lower'"
    ),
)

The subprocess itself succeeds. During structured-output parsing, JSON null becomes Python None, and the implementation calls .lower() on it.

The resulting AttributeError reaches the outer execution-error handler, which discards the already captured subprocess result and reports a synthetic exit code of -1.

Expected Behavior

The documented command-hook contract states that exit code 0 represents successful execution.

A null decision does not represent either "allow" or "deny", so it should be treated as absent or unsupported without causing an internal type error.

The result should preserve the observed subprocess outcome:

result.success is True
result.exit_code == 0
result.decision is None
result.error is None

The original stdout should also remain available for diagnostics.

Valid string decisions such as "allow" and "deny" should continue to behave as they do currently.

Actual Behavior

Running the reproduction with:

uv run pytest \
    tests/sdk/hooks/test_executor_null_decision.py \
    -q

fails because the returned result contains:

success=False
exit_code=-1
stdout=''
decision=None
error="Hook execution failed: 'NoneType' object has no attribute 'lower'"

The real subprocess exit status of 0 and its captured stdout are replaced by the outer exception handler.

Steps to Reproduce
  1. Check out release v1.44.1 at commit 9d143aac35c2dcec9cbb046ff9f35ac5eb072f6a.

  2. Set up the development environment:

make build
  1. Create tests/sdk/hooks/test_executor_null_decision.py:
from openhands.sdk.hooks.config import HookDefinition
from openhands.sdk.hooks.executor import HookExecutor
from openhands.sdk.hooks.types import HookEvent, HookEventType
from tests.command_utils import python_command


def test_execute_null_decision_preserves_exit_zero(tmp_path):
    executor = HookExecutor(working_dir=str(tmp_path))
    event = HookEvent(
        event_type=HookEventType.PRE_TOOL_USE,
        tool_name="BashTool",
        tool_input={"command": "ls -la"},
        session_id="test-session",
    )
    hook = HookDefinition(
        command=python_command(
            "import json; "
            "print(json.dumps({'decision': None}))"
        ),
    )

    result = executor.execute(hook, event)

    assert result.success is True
    assert result.exit_code == 0
    assert result.stdout.strip() == '{"decision": null}'
    assert result.decision is None
    assert result.error is None
  1. Run:
uv run pytest \
    tests/sdk/hooks/test_executor_null_decision.py \
    -q
  1. Observe that the test fails at result.success is True. The returned result instead reports success=False and exit_code=-1.
Acceptance Criteria
  • An exit-0 command hook with "decision": null does not trigger an internal AttributeError.
  • The returned result preserves success=True and the actual exit code 0.
  • A null or otherwise unsupported decision value does not become an ALLOW or DENY decision.
  • Captured stdout and stderr are not discarded because of decision-field parsing.
  • Existing "allow" and "deny" behavior remains unchanged.
  • Regression tests cover missing, null, unsupported-string, "allow", and "deny" decision values.
Installation Method

Source checkout using make build (uv sync --dev)

If you selected "Other", please specify

Not applicable

SDK Version

1.44.1, main@9d143aac35c2dcec9cbb046ff9f35ac5eb072f6a

Version Confirmation
  • I have confirmed this bug exists on the LATEST version of OpenHands SDK
Python Version

3.13.2

Model Name (if applicable)

Not applicable; reproduced by directly invoking deterministic command-hook execution code.

Operating System

MacOS

Logs and Error Messages
Input stdout:
{"decision": null}

Observed result:
success=False
blocked=False
exit_code=-1
stdout=''
stderr=''
decision=None
error="Hook execution failed: 'NoneType' object has no attribute 'lower'"

A string control value does not produce the execution error:

Input stdout:
{"decision": "unsupported"}

Observed result:
success=True
blocked=False
exit_code=0
stdout='{"decision": "unsupported"}\n'
decision=None
error=None

Representative assertion:

AssertionError: assert False is True
 +  where False = HookResult(
     success=False,
     blocked=False,
     exit_code=-1,
     stdout='',
     stderr='',
     decision=None,
     reason=None,
     additional_context=None,
     error="Hook execution failed: 'NoneType' object has no attribute 'lower'",
     async_started=False,
   ).success
Minimal Code Sample
from tempfile import TemporaryDirectory

from openhands.sdk.hooks.config import HookDefinition
from openhands.sdk.hooks.executor import HookExecutor
from openhands.sdk.hooks.types import HookEvent, HookEventType
from tests.command_utils import python_command


with TemporaryDirectory() as working_dir:
    executor = HookExecutor(working_dir=working_dir)
    event = HookEvent(
        event_type=HookEventType.PRE_TOOL_USE,
        tool_name="BashTool",
        tool_input={"command": "ls"},
        session_id="demo",
    )
    hook = HookDefinition(
        command=python_command(
            "import json; "
            "print(json.dumps({'decision': None}))"
        ),
    )

    result = executor.execute(hook, event)
    print(result)
    assert result.success is True
Screenshots and Additional Context

No screenshot is required; this is a deterministic unit-level reproduction using a local subprocess.

The executor initially constructs the correct result from the completed process:

hook_result = HookResult(
    success=result.returncode == 0,
    blocked=result.returncode == 2,
    exit_code=result.returncode,
    stdout=result.stdout,
    stderr=result.stderr,
)

It then parses the optional decision field without checking its type:

if "decision" in output_data:
    decision_str = output_data["decision"].lower()

The inner handler catches only malformed JSON:

except json.JSONDecodeError:
    pass

Consequently, the AttributeError raised by None.lower() reaches the outer handler:

except Exception as e:
    return HookResult(
        success=False,
        exit_code=-1,
        error=f"Hook execution failed: {e}",
    )

This replacement loses the subprocess's real exit code, stdout, and stderr even though execution completed normally.

A possible fix is to validate the decision value before applying string methods:

decision = output_data.get("decision")

if isinstance(decision, str):
    decision_str = decision.lower()
    if decision_str == "allow":
        hook_result.decision = HookDecision.ALLOW
    elif decision_str == "deny":
        hook_result.decision = HookDecision.DENY
        hook_result.blocked = True

Null and other unsupported decision values can then remain unclassified without converting successful command execution into an unrelated internal failure.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in openhands/sdk/hooks/executor.py at HookExecutor.execute and run tests/sdk/hooks/test_executor_null_decision.py. Review the decision parsing after the subprocess result is created, then expand regression coverage for missing, null, unsupported, allow, and deny values. Done means exit-0 results retain success, exit code, captured output, and an unclassified decision when appropriate.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
tooling
Issue type
Bug
Difficulty
2/5
Estimated time
1-3 hours
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
84/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.