Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions libs/code/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,5 +66,6 @@ The main cost is the client/server boundary. When debugging, first decide which

- For local setup and debugging, see [`DEVELOPMENT.md`](./DEVELOPMENT.md).
- For command behavior, see [`COMMANDS.md`](./COMMANDS.md).
- For lifecycle hooks (`hooks.json`), see [`HOOKS.md`](./HOOKS.md).
- For security boundaries, see [`THREAT_MODEL.md`](./THREAT_MODEL.md).
- For package-specific coding conventions, see [`AGENTS.md`](./AGENTS.md).
121 changes: 121 additions & 0 deletions libs/code/HOOKS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# Hooks

Hooks are user-configured shell commands that run at agent lifecycle events. Each matching handler receives a JSON event payload on stdin and may influence the session through its exit code and stdout.

> **Warning:** Hook commands run on your machine with your user privileges. Treat every `hooks.json` entry as code you trust — especially project-scoped hooks checked into a repository.

## Configuration locations and precedence

| Scope | Path | When it loads |
| --- | --- | --- |
| User | `~/.deepagents/hooks.json` | Always (when the file exists) |
| Project | `{project_root}/.deepagents/hooks.json` | Only after workspace trust |

When both scopes load, project matcher groups are applied first, then user groups. A project handler that stops further processing therefore wins over lower-precedence user handlers for the same event.

### Project workspace trust

Project-scoped hooks can execute arbitrary commands from the repository. Before they load:

- Interactive `dcode` prompts for approval when `.deepagents/hooks.json` is present and the workspace is not already trusted.
- Choosing always-allow persists trust for that canonical workspace root in `~/.deepagents/.state/hooks_trust.json`.
- Cancelling the prompt (Esc / Ctrl+D) aborts startup.
- Denying skips project hooks for the session and continues with user hooks only.
- Headless / CI runs do not prompt; pass `--trust-project-hooks` to opt in for that run.

## Events and matchers

Each top-level key under `"hooks"` is an event name. Values are lists of matcher groups. A group may omit `matcher` (or use `"*"`) to match all values for that event's matcher field. Events with no matcher field reject non-wildcard matchers at load time.

Native tools are matched by their wire names (for example `execute` → `Bash`, `write_file` → `Write`).

| Event | Owner | Matcher field | Fires when |
| --- | --- | --- | --- |
| `SessionStart` | client | `cause` | A session starts (`startup`, `resume`, `clear`, `compact`) |
| `UserPromptSubmit` | client | _(none)_ | The user submits a prompt |
| `SessionEnd` | client | `cause` | A session ends |
| `PermissionRequest` | client | `tool_name` | The client is about to ask for tool permission |
| `Notification` | client | `notification_type` | A client lifecycle notification is emitted |
| `PreToolUse` | server | `tool_name` | Before a tool call runs |
| `PostToolUse` | server | `tool_name` | After a tool call completes |
| `PreCompact` | server | `trigger` | Before conversation compaction |
| `Stop` | server | _(none)_ | After an agent stop turn |
| `SubagentStart` | server | `agent_name` | When a subagent starts |
| `SubagentStop` | server | `agent_name` | When a subagent stops |

## Handler shape

Each matcher group has a `hooks` list of command handlers:

```json
{
"type": "command",
"command": "your-shell-command",
"timeout": 60,
"statusMessage": "Running policy check"
}
```

- `type` must be `"command"`.
- `command` is required and runs through a shell, so pipes, redirects, and `$VAR` expansion work.
- `argv` is optional; when set, the handler is executed directly from that argument list instead of through a shell.
- `timeout` is optional seconds; when omitted, the event default applies (600s for most events, 30s for `UserPromptSubmit`).
- `statusMessage` is optional UI status text while the handler runs.
- `async: true` is rejected; async command hooks are not supported.

## Examples

### Minimal

```json
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "true"
}
]
}
]
}
}
```

### Deny a destructive shell command

Matchers use wire tool names. `execute` is exposed as `Bash`. Exit code `2` (or JSON `permissionDecision: "deny"`) denies `PreToolUse`:

```json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 -c \"import json,sys; d=json.load(sys.stdin); cmd=d.get('tool_input',{}).get('command',''); blocked='rm -rf /' in cmd; print(json.dumps({'hookSpecificOutput':{'hookEventName':'PreToolUse','permissionDecision':'deny','permissionDecisionReason':'Refusing destructive root delete'}}) if blocked else '{}')\""
}
]
}
]
}
}
```

## How handler output affects behavior

Handlers communicate through:

- **Exit code `2`**: treated as a synthetic `decision: "block"`. Interpretation depends on the event (for example deny on `PreToolUse` / `PermissionRequest`, block further processing on `UserPromptSubmit` / `PreCompact`, feedback on `PostToolUse`).
- **Other non-zero exits**: recorded as diagnostics; they do not apply a block decision.
- **JSON stdout** (`HookWireOutput`): may set `continue` / `stopReason`, `systemMessage` (user-visible notice), `additionalContext` via `hookSpecificOutput`, and event-specific fields such as `permissionDecision` on `PreToolUse`.
- **Non-JSON stdout**: becomes additional context for events whose plain-output policy is context (`SessionStart`, `UserPromptSubmit`); otherwise it is a diagnostic.
- **Timeouts**: when a handler exceeds its timeout, it is terminated and recorded as a timeout diagnostic; it does not apply a successful decision.

## Legacy configuration

Older list-shaped `hooks.json` documents are still loaded. Semantically equivalent legacy events are migrated into the Hooks v2 shape automatically; unsupported legacy events are left unmapped and surfaced as load diagnostics.
3 changes: 2 additions & 1 deletion libs/code/THREAT_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -463,7 +463,7 @@ Threats that appear valid in isolation but fall outside project responsibility b
| Malicious MCP server injecting prompt instructions | Users configure MCP servers and explicitly trust project-level configs. Once trusted, MCP tool outputs are data from a system the user controls. | Interactive approval prompt + per-server allow/deny lists for project-level configs (`main._check_mcp_project_trust`, `model_config.load_mcp_server_trust_lists`). |
| LLM jailbreak / safety bypass | Model selection and safety configuration are user-controlled. The project routes prompts to the configured LLM but cannot guarantee model behavior. | Correctly routing prompts to the configured LLM; applying the system prompt from `agent.get_system_prompt`. |
| Sandbox provider security vulnerabilities | Daytona, LangSmith, Modal, Runloop, and AgentCore are third-party services. Their internal security is not this project's responsibility. | Correctly initializing sandbox sessions via `integrations.sandbox_factory.create_sandbox`. |
| Hook commands doing harmful things | Hooks in `~/.deepagents/hooks.json` are 100% user-authored. The payload is data-only (JSON on stdin). | JSON structure validation (`hooks._load_hooks`); 5-second timeout. |
| Hook commands doing harmful things | User-scoped hooks (`~/.deepagents/hooks.json`) and project-scoped hooks (`.deepagents/hooks.json`, only after interactive workspace trust or `--trust-project-hooks`) are intentionally configured commands. The payload is data-only (JSON on stdin). | Schema validation (`hooks.loading.load_hooks_config`); workspace trust for project hooks (versioned store under `~/.deepagents/.state/hooks_trust.json`; cancelling the trust prompt aborts startup); bounded execution with per-event default timeouts (600s for most events, 30s for `UserPromptSubmit`); sanitized subprocess environment. |
| Async subagent traffic interception / MitM | Async subagents connect to user-configured LangGraph deployment URLs. The project does not control those endpoints or their TLS certificates. | Accepting URL/headers from user config and passing them to the LangGraph SDK (`agent.load_async_subagents`). |
| LangGraph dev server port enumeration / discovery | Discovering the local dev server port requires local access. Port scanning localhost is a general OS security concern, not a framework vulnerability. | Binding to `127.0.0.1` by default (`server._DEFAULT_HOST`); ephemeral server lifetime; OS-assigned ephemeral port (`server._EPHEMERAL_PORT`) is not predictable across runs. |
| `.env` file from parent directory changes app/API configuration | `config._find_dotenv_from_start_path` walks up the directory tree to find `.env` files. Discovering ordinary configuration values (API keys, `DEEPAGENTS_CODE_*` settings) this way is standard `python-dotenv` behavior, and the user controls their filesystem. The *code-execution* implication of a project `.env` (shell startup hooks) is tracked in-scope as T12. | Finding `.env` from the project root (`config._find_dotenv_from_start_path`); `override=False` by default (existing env vars preserved); shell startup / environment-hijack keys (`BASH_ENV`, `ENV`) denied during dotenv loading. |
Expand Down Expand Up @@ -503,3 +503,4 @@ Threats that appear valid in isolation but fall outside project responsibility b
| 2026-07-08 | manual update | Removed the SHA-256 config fingerprint trust store (`mcp_trust.py`, `~/.deepagents/.state/mcp_trust.json`, DC4). Project MCP trust is now the interactive approval prompt (allow-for-session / always-allow scoped to project root + server-definition fingerprint / deny), the `--trust-project-mcp` run flag, the `[mcp].enabled_project_server_approvals` and `[mcp].disabled_project_servers` lists, and the process-wide `DEEPAGENTS_CODE_DANGEROUSLY_ENABLE_PROJECT_MCP_SERVERS` name-based escape hatch. The legacy flat `[mcp].enabled_project_servers` key is ignored. Persisted approvals bind to a server definition's fingerprint rather than a whole-config fingerprint. Updated C5, TB4, T10, the configuration input-coverage row, and the malicious-MCP-server dismissal accordingly |
| 2026-07-21 | manual update | Clarified TB4 after process-wide MCP names and scoped remembered approvals changed from replacement semantics to independent grants. An empty process-wide allowlist no longer suppresses remembered approvals; disabled-server precedence is unchanged. |
| 2026-07-22 | manual update | Added T13 (first-token shell allow-list bypass via allow-listed interpreters/wrappers) under TB2, distinguishing it from T2's `--shell-allow-list all` sentinel; cross-referenced it from T2 and extended the "LLM output" input-coverage gap to note that allow-list matching only inspects the command's first token |
| 2026-07-24 | manual update | Corrected the out-of-scope hooks row: Hooks v2 defaults are 600s (30s for `UserPromptSubmit`), not a global 5-second timeout; loading is `hooks.loading.load_hooks_config`; project hooks require workspace trust or `--trust-project-hooks` |
20 changes: 17 additions & 3 deletions libs/code/deepagents_code/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,7 @@ class _ConfigWriteResult:
from deepagents_code.hooks.models.domain import (
SessionStartCause,
)
from deepagents_code.hooks.presenter import HookNoticeSeverity
from deepagents_code.hooks.trust import WorkspaceTrust
from deepagents_code.mcp_tools import MCPServerInfo
from deepagents_code.model_config import MissingProviderPackageError
Expand Down Expand Up @@ -2282,7 +2283,6 @@ def __init__(
self.hooks: HooksManager = HooksManager.adopting(
None,
identity=self.hook_identity,
notice=lambda _message: None,
)
"""Client-side Hooks v2 coordinator.

Expand Down Expand Up @@ -4367,6 +4367,18 @@ async def _post_paint_init(self) -> None:
lambda: asyncio.create_task(self._run_session_start_sequence()),
)

def _notify_hook_feedback(
self,
message: str,
severity: HookNoticeSeverity,
) -> None:
self.notify(message, severity=severity, markup=False)

def _update_hook_status(self, message: str) -> None:
"""Update the status bar with hook-owned progress text."""
if self._status_bar:
self._status_bar.set_status_message(message, source="hooks")

async def _init_session_state(self) -> None:
"""Create session state and load its Hooks v2 manager.

Expand Down Expand Up @@ -4402,7 +4414,8 @@ async def _init_session_state(self) -> None:
session_state.hooks = HooksManager.create(
cwd=Path(self._cwd),
identity=session_state.hook_identity,
notice=lambda message: self.notify(message, markup=False),
notice=self._notify_hook_feedback,
status=self._update_hook_status,
trust=self._hook_trust,
)
# Re-read the app-owned selection last so a mode change during
Expand All @@ -4423,7 +4436,8 @@ def _hooks(self) -> HooksManager:
self._detached_hooks = HooksManager.adopting(
None,
identity=self._hook_identity,
notice=lambda message: self.notify(message, markup=False),
notice=self._notify_hook_feedback,
status=self._update_hook_status,
)
return self._detached_hooks

Expand Down
94 changes: 87 additions & 7 deletions libs/code/deepagents_code/client/non_interactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@
from deepagents_code.approval_mode import ApprovalMode
from deepagents_code.hooks.manager import HooksManager
from deepagents_code.hooks.models.domain import SessionEndCause
from deepagents_code.hooks.presenter import (
HookNoticeCallback,
HookNoticeSeverity,
)
from deepagents_code.hooks.transcript import TranscriptRecorder

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -192,6 +196,11 @@ def __init__(self, console: Console) -> None:
self._console = console
self._live: Live | None = None

@property
def is_running(self) -> bool:
"""Whether the live spinner is active."""
return self._live is not None

def start(self, message: str = "Working...") -> None:
"""Start the spinner with the given message.

Expand All @@ -203,18 +212,27 @@ def start(self, message: str = "Working...") -> None:
"""
if self._live is not None:
return
renderable = RichSpinner(
"dots",
text=Text(f" {message}", style="dim"),
style="dim",
)
renderable = self._build_spinner(message)
try:
self._live = Live(renderable, console=self._console, transient=True)
self._live.start()
except (AttributeError, TypeError, OSError) as exc:
logger.warning("Spinner start failed: %s", exc)
self._live = None

def update(self, message: str) -> None:
"""Replace the message on a running spinner.

Args:
message: Status text to display next to the spinner.
"""
if self._live is None:
return
try:
self._live.update(self._build_spinner(message))
except (AttributeError, TypeError, OSError) as exc:
logger.warning("Spinner update failed: %s", exc)

def stop(self) -> None:
"""Stop the spinner if running. Can be restarted with `start`."""
if self._live is not None:
Expand All @@ -225,6 +243,14 @@ def stop(self) -> None:
finally:
self._live = None

@staticmethod
def _build_spinner(message: str) -> RichSpinner:
return RichSpinner(
"dots",
text=Text(f" {message}", style="dim"),
style="dim",
)


async def _terminate_startup_process(proc: Process) -> None:
"""Terminate and reap a startup command subprocess.
Expand Down Expand Up @@ -329,6 +355,26 @@ def _inert_hooks() -> HooksManager:
return HooksManager.inert()


def _plain_hook_notice(console: Console) -> HookNoticeCallback:
"""Build a notice sink that prints hook output without styling.

Used for hooks loaded before the run owns a spinner; `attach_output` later
rebinds the manager's presenter to the styled, spinner-aware sinks.

Args:
console: Destination for notice text.

Returns:
An unstyled notice sink.
"""

def notice(message: str, severity: HookNoticeSeverity) -> None:
del severity
console.print(Text(message), highlight=False)

return notice


@dataclass
class StreamState:
"""Mutable state accumulated while iterating over the agent stream."""
Expand Down Expand Up @@ -1344,6 +1390,38 @@ async def _run_agent_loop(
)
from deepagents_code.hooks.trust import WorkspaceTrust

hook_owned_spinner = False

def present_hook_notice(
message: str,
severity: HookNoticeSeverity,
) -> None:
style = (
"bold red"
if severity == "error"
else "yellow"
if severity == "warning"
else "dim"
)
console.print(Text(message, style=style), highlight=False)

def update_hook_status(message: str) -> None:
nonlocal hook_owned_spinner
if spinner is None:
return
if message:
if spinner.is_running:
spinner.update(message)
else:
spinner.start(message)
hook_owned_spinner = True
elif hook_owned_spinner:
spinner.stop()
hook_owned_spinner = False
elif spinner.is_running:
spinner.update("Working...")

hook_status = update_hook_status if spinner is not None else None
resolved_approval_mode = approval_mode or ApprovalMode.MANUAL
# One headless turn per process, so identity is fixed for the whole run.
identity = HookSessionIdentity(
Expand All @@ -1354,12 +1432,12 @@ async def _run_agent_loop(
state.hooks = hooks or HooksManager.create(
cwd=Path.cwd(),
identity=lambda: identity,
notice=lambda notice: console.print(Text(notice), highlight=False),
# Project hooks require an explicit opt-in, matching `--trust-project-mcp`.
# Persisted interactive trust deliberately does not carry into headless
# runs, so CI never inherits a grant made at someone's terminal.
trust=WorkspaceTrust.explicit_only(Path.cwd(), granted=trust_project_hooks),
)
state.hooks.attach_output(notice=present_hook_notice, status=hook_status)
state.hooks.apply_graph_context(context)
context["approval_mode"] = resolved_approval_mode.value
context["auto_approve"] = resolved_approval_mode is ApprovalMode.YOLO
Expand Down Expand Up @@ -1963,7 +2041,9 @@ def discover_all_skills() -> tuple[list[ExtendedSkillMetadata], list[Path]]:
hooks = HooksManager.create(
cwd=Path.cwd(),
identity=lambda: identity,
notice=lambda notice: console.print(Text(notice), highlight=False),
# Plain output until `_run_agent_loop` attaches the styled,
# spinner-aware sinks to this same presenter.
notice=_plain_hook_notice(console),
# Explicit opt-in only; see `_run_agent_loop` for the rationale.
trust=WorkspaceTrust.explicit_only(Path.cwd(), granted=trust_project_hooks),
)
Expand Down
Loading