Skip to content

Commit 0c84d85

Browse files
committed
fix(bash): surface clearer install workflow guidance
1 parent ce84a6a commit 0c84d85

4 files changed

Lines changed: 90 additions & 9 deletions

File tree

src/openharness/permissions/checker.py

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -141,14 +141,18 @@ def evaluate(
141141
)
142142

143143
# Default mode: require confirmation for mutating tools
144+
bash_hint = _bash_permission_hint(command)
145+
reason = (
146+
"Mutating tools require user confirmation in default mode. "
147+
"Approve the prompt when asked, or run /permissions full_auto "
148+
"if you want to allow them for this session."
149+
)
150+
if bash_hint:
151+
reason = f"{reason} {bash_hint}"
144152
return PermissionDecision(
145153
allowed=False,
146154
requires_confirmation=True,
147-
reason=(
148-
"Mutating tools require user confirmation in default mode. "
149-
"Approve the prompt when asked, or run /permissions full_auto "
150-
"if you want to allow them for this session."
151-
),
155+
reason=reason,
152156
)
153157

154158

@@ -163,3 +167,34 @@ def _policy_match_paths(file_path: str) -> tuple[str, ...]:
163167
if not normalized:
164168
return (file_path,)
165169
return (normalized, normalized + "/")
170+
171+
172+
def _bash_permission_hint(command: str | None) -> str:
173+
if not command:
174+
return ""
175+
lowered = command.lower()
176+
install_markers = (
177+
"npm install",
178+
"pnpm install",
179+
"yarn install",
180+
"bun install",
181+
"pip install",
182+
"uv pip install",
183+
"poetry install",
184+
"cargo install",
185+
"create-next-app",
186+
"npm create ",
187+
"pnpm create ",
188+
"yarn create ",
189+
"bun create ",
190+
"npx create-",
191+
"npm init ",
192+
"pnpm init ",
193+
"yarn init ",
194+
)
195+
if any(marker in lowered for marker in install_markers):
196+
return (
197+
"Package installation and scaffolding commands change the workspace, "
198+
"so they will not run automatically in default mode."
199+
)
200+
return ""

src/openharness/tools/bash_tool.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@ class BashTool(BaseTool):
3030

3131
async def execute(self, arguments: BashToolInput, context: ToolExecutionContext) -> ToolResult:
3232
cwd = Path(arguments.cwd).expanduser() if arguments.cwd else context.cwd
33+
preflight_error = _preflight_interactive_command(arguments.command)
34+
if preflight_error is not None:
35+
return ToolResult(
36+
output=preflight_error,
37+
is_error=True,
38+
metadata={"interactive_required": True},
39+
)
3340
process: asyncio.subprocess.Process | None = None
3441
try:
3542
process = await create_shell_subprocess(
@@ -134,6 +141,18 @@ def _format_timeout_output(output_buffer: bytearray, *, command: str, timeout_se
134141
return "\n".join(parts)
135142

136143

144+
def _preflight_interactive_command(command: str) -> str | None:
145+
lowered_command = command.lower()
146+
if not _looks_like_interactive_scaffold(lowered_command):
147+
return None
148+
return (
149+
"This command appears to require interactive input before it can continue. "
150+
"The bash tool is non-interactive, so it cannot answer installer/scaffold prompts live. "
151+
"Prefer non-interactive flags (for example --yes, -y, --skip-install, --defaults, --non-interactive), "
152+
"or run the scaffolding step once in an external terminal before asking the agent to continue."
153+
)
154+
155+
137156
def _interactive_command_hint(*, command: str, output: str) -> str | None:
138157
lowered_command = command.lower()
139158
if _looks_like_interactive_scaffold(lowered_command) or _looks_like_prompt(output):

tests/test_permissions/test_checker.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,18 @@ def test_default_mode_requires_confirmation_for_mutation():
2424
assert "/permissions full_auto" in decision.reason
2525

2626

27+
def test_default_mode_gives_package_install_hint_for_bash():
28+
checker = PermissionChecker(PermissionSettings(mode=PermissionMode.DEFAULT))
29+
decision = checker.evaluate(
30+
"bash",
31+
is_read_only=False,
32+
command="npm init -y && npm install next react react-dom",
33+
)
34+
assert decision.allowed is False
35+
assert decision.requires_confirmation is True
36+
assert "Package installation and scaffolding commands change the workspace" in decision.reason
37+
38+
2739
def test_plan_mode_blocks_mutating_tools():
2840
checker = PermissionChecker(PermissionSettings(mode=PermissionMode.PLAN))
2941
decision = checker.evaluate("bash", is_read_only=False)

tests/test_tools/test_bash_tool.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ def kill(self):
6666

6767

6868
@pytest.mark.asyncio
69-
async def test_bash_tool_timeout_returns_partial_output_and_interactive_hint(monkeypatch, tmp_path: Path):
69+
async def test_bash_tool_preflight_short_circuits_interactive_scaffold_even_with_timeout_fixture(monkeypatch, tmp_path: Path):
7070
process = _FakeProcess(
7171
stdout=_FakeStdout(
7272
[
@@ -91,9 +91,24 @@ async def fake_create_shell_subprocess(*args, **kwargs):
9191
)
9292

9393
assert result.is_error is True
94-
assert "Command timed out after 1 seconds." in result.output
95-
assert "This command appears to require interactive input." in result.output
96-
assert result.metadata["timed_out"] is True
94+
assert "This command appears to require interactive input before it can continue." in result.output
95+
assert result.metadata["interactive_required"] is True
96+
97+
98+
@pytest.mark.asyncio
99+
async def test_bash_tool_preflights_interactive_scaffold_commands(tmp_path: Path):
100+
result = await BashTool().execute(
101+
BashToolInput(
102+
command='npx create-next-app@latest coolblog --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"',
103+
timeout_seconds=1,
104+
),
105+
ToolExecutionContext(cwd=tmp_path),
106+
)
107+
108+
assert result.is_error is True
109+
assert result.metadata["interactive_required"] is True
110+
assert "cannot answer installer/scaffold prompts live" in result.output
111+
assert "non-interactive flags" in result.output
97112

98113

99114
@pytest.mark.asyncio

0 commit comments

Comments
 (0)