Skip to content
29 changes: 20 additions & 9 deletions libs/code/deepagents_code/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3869,7 +3869,7 @@ def _check_mcp_project_trust(
return True


_PROJECT_HOOKS_REMEMBER_LABEL = "Always allow hooks in this workspace"
_PROJECT_HOOKS_REMEMBER_LABEL = "Always allow hooks in this project"


def _check_project_hooks_trust(
Expand All @@ -3891,9 +3891,8 @@ def _check_project_hooks_trust(
abort startup.
"""
from rich.console import Console
from rich.text import Text

from deepagents_code.hooks.loading import project_hooks_path
from deepagents_code.hooks.loading import project_hooks_path, user_hooks_path
from deepagents_code.hooks.trust import (
WorkspaceTrust,
is_project_hooks_trusted,
Expand All @@ -3905,6 +3904,11 @@ def _check_project_hooks_trust(
context = ProjectContext.from_user_cwd(Path.cwd())
project_root = context.project_root or context.user_cwd
config_path = project_hooks_path(project_root)
if config_path.resolve(strict=False) == user_hooks_path().resolve(strict=False):
# Running from the user config's parent makes the user hooks path
# look project-scoped. It needs no trust decision and must not be
# granted project trust under the wrong provenance.
return WorkspaceTrust.none()
if not config_path.is_file():
return WorkspaceTrust.none()
except OSError:
Expand All @@ -3915,14 +3919,20 @@ def _check_project_hooks_trust(
if trust_flag or is_project_hooks_trusted(project_root):
return granted

from rich.markup import escape

prompt_console = Console(stderr=True)
prompt_console.print()
title = Text("Project hooks can execute commands from ", style="bold yellow")
title.append(str(config_path))
prompt_console.print(title, highlight=False)
prompt_console.print(
"Only allow hooks for projects you trust. Future edits to this file "
"will run without asking again if you always allow.",
"[bold yellow]Project hooks can run arbitrary shell commands on your "
"machine.[/bold yellow]",
highlight=False,
)
prompt_console.print(f"Hooks file: {escape(str(config_path))}", highlight=False)
prompt_console.print(
"Only trust projects you control. Allow once runs this file as it is "
f'now; always allow trusts "{escape(str(project_root))}" for future '
"sessions and future edits.",
style="yellow",
highlight=False,
)
Expand All @@ -3949,7 +3959,8 @@ def _check_project_hooks_trust(
)
else:
prompt_console.print(
"[dim]Project hooks trusted for this workspace.[/dim]",
f'[dim]Hooks for "{escape(str(project_root))}" will run without '
"asking from now on.[/dim]",
highlight=False,
)
return granted
Expand Down
10 changes: 6 additions & 4 deletions libs/code/deepagents_code/tui/widgets/cwd_switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,15 +281,17 @@ def compose(self) -> ComposeResult:
"""
with Vertical():
yield Static(
"Project hooks can execute commands",
"Project hooks can run arbitrary shell commands on your machine",
classes="cwd-switch-title",
markup=False,
)
yield Static(
Content.from_markup(
"The workspace [bold]$root[/bold] contains project hooks at "
"[bold]$path[/bold]. Only allow hooks for projects you trust. "
"Always allow also trusts future edits to this file.",
"[bold]$root[/bold] contains project hooks at "
"[bold]$path[/bold]. Only trust projects you control. "
'"Allow once" runs the file as it is now; "always allow" '
"trusts [bold]$root[/bold] for future sessions and future "
"edits.",
root=self._project_root,
path=self._config_path,
),
Expand Down
104 changes: 102 additions & 2 deletions libs/code/tests/unit_tests/hooks/test_trust.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import pytest

from deepagents_code.approval_mode import ApprovalMode
from deepagents_code.hooks.loading import project_hooks_path
from deepagents_code.hooks.manager import HookSessionIdentity, HooksManager
from deepagents_code.hooks.models.domain import (
HookContext,
Expand All @@ -32,8 +33,16 @@
from deepagents_code.app import DeepAgentsApp


def _write_project_hooks(root: Path, *, event: str = "Stop") -> Path:
(root / ".git").mkdir(parents=True, exist_ok=True)
def _write_project_hooks(
root: Path,
*,
event: str = "Stop",
git: bool = True,
) -> Path:
if git:
(root / ".git").mkdir(parents=True, exist_ok=True)
else:
root.mkdir(parents=True, exist_ok=True)
hooks_dir = root / ".deepagents"
hooks_dir.mkdir(exist_ok=True)
(hooks_dir / "hooks.json").write_text(
Expand Down Expand Up @@ -222,6 +231,97 @@ async def test_runtime_refuses_loaded_project_hooks_without_trust(
await runtime.invoke(invocation)


def test_non_git_workspace_without_hooks_skips_prompt(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from deepagents_code.hooks import trust
from deepagents_code.main import _check_project_hooks_trust

# No .git or project hooks exist anywhere under tmp_path.
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(
trust, "_default_store_path", lambda: tmp_path / "state" / "hooks_trust.json"
)
monkeypatch.setattr(
"deepagents_code.main._select_trust_action",
lambda *_args, **_kwargs: pytest.fail("prompt ran without project hooks"),
)

decision = _check_project_hooks_trust()
assert isinstance(decision, WorkspaceTrust)
assert not decision.allows(tmp_path)


def test_explicit_trust_allows_non_git_project_hooks(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from deepagents_code.main import _check_project_hooks_trust

root = _write_project_hooks(tmp_path / "project", git=False)
monkeypatch.chdir(root)
monkeypatch.setattr(
"deepagents_code.main._select_trust_action",
lambda *_args, **_kwargs: pytest.fail("prompt ran despite explicit trust"),
)

decision = _check_project_hooks_trust(trust_flag=True)
assert isinstance(decision, WorkspaceTrust)
assert decision.allows(root)


def test_user_hooks_path_collision_skips_prompt(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from deepagents_code.hooks import loading, trust
from deepagents_code.main import _check_project_hooks_trust

home = _write_project_hooks(tmp_path / "home", git=False)
monkeypatch.chdir(home)
monkeypatch.setattr(loading, "DEFAULT_CONFIG_DIR", home / ".deepagents")
monkeypatch.setattr(
trust, "_default_store_path", lambda: tmp_path / "state" / "hooks_trust.json"
)
monkeypatch.setattr(
"deepagents_code.main._select_trust_action",
lambda *_args, **_kwargs: pytest.fail("prompt ran for user hooks"),
)

decision = _check_project_hooks_trust(trust_flag=True)
assert isinstance(decision, WorkspaceTrust)
assert not decision.allows(home)


def test_prompt_renders_paths_containing_markup(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
from deepagents_code.hooks import trust
from deepagents_code.main import _check_project_hooks_trust, _TrustAction

# Rich consumes `[bold]` as a style tag, so an unescaped path would render
# as "projx" — silently wrong in the prompt the trust decision rests on.
root = _write_project_hooks(tmp_path / "proj[bold]x")
monkeypatch.chdir(root)
monkeypatch.setenv("COLUMNS", "400")
monkeypatch.setattr(
trust, "_default_store_path", lambda: tmp_path / "state" / "hooks_trust.json"
)
monkeypatch.setattr(
"deepagents_code.main._select_trust_action",
lambda *_args, **_kwargs: _TrustAction.REMEMBER,
)

_check_project_hooks_trust()

err = capsys.readouterr().err
assert "proj[bold]x" in err
assert str(project_hooks_path(root)) in err


@pytest.mark.parametrize(
("action", "allowed", "persisted"),
[("REMEMBER", True, True), ("ALLOW_ONCE", True, False), ("DENY", False, False)],
Expand Down