OpenHands / OpenHands/software-agent-sdk
[Bug]: HookExecutor converts exit-0 hooks with decision: null into execution failures
Nobody has claimed this yet.
- 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
-
Check out release
v1.44.1at commit9d143aac35c2dcec9cbb046ff9f35ac5eb072f6a. -
Set up the development environment:
make build
- 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
- Run:
uv run pytest \
tests/sdk/hooks/test_executor_null_decision.py \
-q
- Observe that the test fails at
result.success is True. The returned result instead reportssuccess=Falseandexit_code=-1.
Acceptance Criteria
- An exit-0 command hook with
"decision": nulldoes not trigger an internalAttributeError. - The returned result preserves
success=Trueand the actual exit code0. - A null or otherwise unsupported decision value does not become an
ALLOWorDENYdecision. - 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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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