Files
deepagents/libs/code/deepagents_code/approval_mode.py
Mason Daugherty eae7c28f11 feat(code): classifier-backed Auto approval mode (#4804)
Deep Agents Code now offers an opt-in **Auto** approval mode. Auto
immediately runs a narrow, deterministic set of routine actions. For
other approval-gated actions, the active model checks whether they match
the user's literal request. It runs approved actions and blocks the
rest. After repeated denials or classifier failures, Auto sends the next
batch to the normal approval UI, then continues in Auto mode.

Auto is experimental and currently limited to interactive, local TUI
sessions using `FilesystemBackend`. Manual remains the default.

> [!WARNING]
> Auto is an authorization heuristic for a local coding agent. It is
**not** sandbox containment, an operating-system boundary, or a
guarantee that model-generated actions are safe.

## Enable Auto

1. Set `DEEPAGENTS_CODE_EXPERIMENTAL=1`.
2. Select Auto in one of these ways:
- In-app: use <kbd>Shift</kbd>+<kbd>Tab</kbd> or
<kbd>Ctrl</kbd>+<kbd>T</kbd> to toggle between Manual and Auto.
   - At launch: pass `-y` or `--auto-approve`.
- By default: set `[startup].mode = "auto"` in
`~/.deepagents/config.toml`.

`-y` and `--auto-approve` now select Auto rather than unrestricted
execution. Users who want the previous unrestricted behavior must choose
`--yolo`. The removed `dangerously-auto` startup value is no longer
recognized and falls back to Manual.

---

## Why

The previous approval modes forced a binary choice:

- **Manual** preserved a human checkpoint, but repeatedly interrupted
routine coding work.
- **YOLO** removed that checkpoint entirely, including for actions that
exceeded the user's request (blanket approval).

Auto introduces a third policy: keep the low-friction path for clearly
routine work, but make authorization explicit for potentially
consequential actions.

| Mode | Policy | Entry points | Availability |
|---|---|---|---|
| **Manual** | Uses the normal human approval UI for gated actions |
Default; `[startup].mode = "manual"` | All interactive sessions |
| **Auto** | Uses narrow deterministic rules, then classifier review,
with human fallback after repeated denials or failures | `-y`,
`--auto-approve`, `[startup].mode = "auto"`, or the live keyboard toggle
| Experimental, local, unsandboxed TUI sessions |
| **YOLO** | Runs gated actions without review | `--yolo` or
`[startup].mode = "yolo"` | Interactive sessions after a one-time
acknowledgement |

## How Auto handles a proposed action

```mermaid
flowchart TD
    A[Model proposes tool calls] --> B{Covered by the approval policy?}
    B -->|No| X[Existing tool behavior is unchanged]
    B -->|Yes| S{Is this thread still in Auto, with readable denial/failure history?}
    S -->|No| J[Open the existing approval HITL widget]
    S -->|Yes| C{Narrow deterministic allow?}
    C -->|Yes| R[Execute without a classifier call]
    C -->|No| D[Build one structured decision batch]
    D --> H{Was this batch already reviewed, or do earlier denials/failures require human review?}
    H -->|Yes| J
    H -->|No| E[Active model reviews effects against user request/input]
    E -->|Allow| R
    E -->|Deny| Q{Total-denial threshold reached?}
    Q -->|No| F[Return a sanitized error result]
    Q -->|Yes| J
    E -->|Unavailable or invalid| G[Return a compact unavailable result]
    F --> I[Agent can revise its plan]
    G --> I
    J -->|Approve| R
    J -->|Reject| K[Return a rejection result]
    J -->|Switch to Manual| L[Persist Manual, then review the full gated batch]
    R --> M[Reconcile the result and continue]
    K --> M
    I --> M
    L --> M
```

In practice:

1. **Auto starts with Manual's existing human-in-the-loop (HITL)
rules.** It changes only how HITL-gated actions are reviewed.
- These built-in tools are not HITL-gated: `ls`, `read_file`, `glob`,
`grep`, `ask_user`, `get_goal`, `get_rubric`, and `update_goal`.
- MCP tools with valid read-only annotations, the optional `js_eval`
tool, and caller-supplied tools without HITL configuration continue to
run normally.
2. **Only narrow cases resolve locally.** The deterministic policy can
allow, never deny:
- A routine write such as `src/parser.py` can proceed based on its
resolved path and suffix; a sensitive target such as
`.github/workflows/ci.yml` goes to classifier review.
- A read-only Git command such as `git status` can proceed; a mutating
command such as `git commit -m change` goes to classifier review.
Explicit shell allow-list entries still apply after Auto removes broad
and wildcard rules.
- An MCP tool with a valid read-only annotation can proceed; tools
without read-only annotations, or with contradictory or malformed
annotations, go to classifier review.

     <details>
     <summary>How MCP read-only annotations are evaluated</summary>

MCP servers can attach standard `ToolAnnotations` when advertising a
tool. Deep Agents Code copies those annotations into the wrapped tool's
metadata and lets the tool bypass classifier review only when:

     - `readOnlyHint` is the literal Boolean `true`;
     - `destructiveHint` is absent, `null`, or `false`; and
- every supplied standard hint (`readOnlyHint`, `destructiveHint`,
`idempotentHint`, or `openWorldHint`) is a Boolean or `null`, rather
than a string or another type.

     For example:

- `readOnlyHint: true, destructiveHint: false` proceeds without
classifier review.
- No `readOnlyHint` goes to review because the tool never explicitly
claimed to be read-only.
- `readOnlyHint: true, destructiveHint: true` goes to review because the
hints contradict each other.
- `readOnlyHint: true, destructiveHint: "false"` goes to review because
`"false"` is a string, not a Boolean.

In Auto, tools that do not pass this check enter the classifier batch.
In Manual, they use the normal approval UI; headless runtimes reject
them because they have no approval UI. The annotation is a
server-provided assertion that Deep Agents Code does not independently
verify.

     </details>
3. **The classifier reviews all unresolved calls together.** Only
pre-expansion literal user text can authorize an action. Referenced
paths, summarized arguments, prior tool calls, and MCP metadata provide
effect context but cannot grant permission. Expanded files, assistant
prose, and tool results are excluded.
- **Example:** For the literal request `Research issue #123, update
@src/parser.py, and open a PR`, proposed `fetch_url` and `git push`
calls can be reviewed in one batch and receive separate decisions. The
referenced path and summarized call arguments help identify what each
action would affect, but the expanded file contents are not sent.
Instructions found in that file, assistant prose, or a prior tool result
therefore cannot widen the user's request or authorize another action.
4. **Auto revalidates before side effects.** The decision plan is bound
to the thread, mode, batch, and exact gated calls. Missing or invalid
state, a mode race, or a replay falls back to human review. Denied calls
are skipped and receive sanitized error results so the agent can revise
its plan.
- **Examples:** If the user switches to Manual while classifier review
is in progress, an earlier Auto decision cannot execute silently; the
normal approval UI opens instead. A plan associated with another thread,
a different set of tool-call IDs, missing decisions, or a previously
processed batch also requires human review. If Auto denies a proposed
`delete`, it returns an error result with that call's ID instead of
invoking the tool, allowing the agent to propose a safer next step.

## Limits and user-visible behavior

- The Manual approval menu can enable Auto for the current thread.
Threshold fallback can switch permanently to Manual or perform a one-off
review while leaving Auto enabled.
- Denials, classifier failures, and fallbacks appear as short sanitized
transcript events rather than raw classifier output.
- Auto requested without the experimental opt-in, or in a sandboxed
session, falls back to Manual with a warning. Explicit autonomous-mode
flags are rejected in non-interactive and ACP entry points.
- The active model is not an independent security authority. MCP
read-only annotations are trusted as a deliberate beta tradeoff.
- Parent-level Auto review does not cover actions performed inside
delegated subagents or broader explicitly configured PTC/`js_eval`
fan-out. Model providers and tracing backends may still observe
classifier inputs and outputs even though the TUI hides them.

<details>
<summary><strong>Implementation and reviewer details</strong></summary>

### Approval boundary and decision inputs

The following tools are HITL-gated:

- `execute`, `write_file`, `edit_file`, and `delete`;
- `web_search` and `fetch_url`;
- `task`;
- `start_async_task`, `update_async_task`, and `cancel_async_task`;
- `compact_conversation` when optional compaction approval is enabled;
and
- MCP tools without valid read-only annotations.

For deterministic writes and edits, the fast path uses only the resolved
target path and suffix; it does not inspect proposed content. Dependency
and lock files, sensitive names and locations, shell scripts,
unsupported file types, and paths outside the trusted root do not
qualify. Selected Git inspection commands must also pass shell-control
and outside-worktree path checks. Configured shell entries remain an
explicit user trust exception, but broad commands and wildcard entries
are ignored by Auto.

The classifier receives one typed batch containing:

- up to 20 client-attached, pre-expansion prompt rows, including literal
prompts, referenced path names, and turn IDs;
- trusted project-root and redacted-origin facts; and
- depth- and length-limited effect context from current arguments, prior
AI tool calls after the latest trusted prompt, and selected MCP
annotations.

Only `literal_user_text` is authorizing. Content-bearing write/edit
arguments are reduced to character counts. The classifier must return
exactly one unique decision for every unresolved tool-call ID; missing,
duplicated, unknown, or malformed decisions make the classifier
unavailable rather than partially authorizing the batch.

Before routing calls, Auto re-reads the live mode and validates its
private decision plan. Manual mode, invalid control state, mismatched
calls, or replayed batches require human review. Classifier review uses
a second call to the active session model with a 20-second timeout. Raw
classifier text and tool-call chunks are hidden from the transcript,
while usage accounting and tracing are retained.

Denial reasons are sanitized before persistence or display by removing
control characters, known credential values, credential-like
assignments, and URL credentials or query values. MCP tools without
valid read-only annotations are HITL-gated; headless runtimes reject
them because no approval UI is available.

### Failure and fallback behavior

| Condition | Result |
|---|---|
| Selected mode cannot be persisted | Persist Manual instead; if that
also fails, block graph execution |
| Classifier timeout, provider error, or invalid response | Block the
affected calls and return a compact unavailable result |
| Three consecutive policy denials in one user turn | Send the next
classifier-review-eligible calls to one-off human review |
| Two consecutive classifier-unavailable results | Send the next
classifier-review-eligible calls to one-off human review |
| A denial raises the thread's total denial count to 20 | Require human
review for that call immediately |
| Counter state cannot be read or reset before classification | Require
human review for every gated call in the batch |
| Counter state cannot be written after classification | Require human
review for classifier-path decisions; deterministic fast-path allows
remain eligible |
| Decision plan is missing, malformed, replayed, or bound to another
thread | Require Manual review |
| User chooses **Switch to Manual** during fallback | Persist Manual
before presenting the full gated batch for review |

### Python API and state compatibility

- Existing Boolean compatibility input retains its previous meaning:
`auto_approve=True` maps to YOLO, not Auto.
- Per-thread Store records persist the typed mode instead of a Boolean.
Missing, legacy, or malformed records fail closed.
- Startup and live mode changes take effect only after the Store write
succeeds.
- YOLO requires a versioned local acknowledgement and cannot be entered
through the live keyboard toggle.

### Reviewer guide

Suggested review order:

1. **Mode semantics and persistence** — `ApprovalMode`, Store payloads,
acknowledgement, startup resolution, and live mode writes.
2. **Authorization policy** — `AutoModeHITLMiddleware`, literal prompt
metadata, deterministic routing, structured classification,
sanitization, counters, and checkpoint validation.
3. **Graph boundary** — middleware replacement, the gated-tool
inventory, MCP annotation handling, headless rejection, PTC, and
delegated-subagent scope.
4. **Client behavior** — stream metadata, classifier-output filtering,
fallback interrupts, keyboard toggles, status presentation, and
transcript events.

Highest-risk invariants:

- every consequential tool in the supported boundary is gated or
explicitly outside scope;
- path-based writes and selected Git fast paths stay within their
documented checks;
- only literal user prompts can authorize classifier-reviewed effects;
- malformed output, Store failures, mode races, and checkpoint replay
fail closed; and
- hidden classifier chunks never create transcript content while usage
accounting remains intact.

</details>
2026-07-17 17:09:26 -04:00

313 lines
9.4 KiB
Python

"""Approval-mode state shared by the Textual client and agent server."""
from __future__ import annotations
import contextlib
import inspect
import json
import logging
import os
import tempfile
from collections.abc import Mapping
from enum import StrEnum
from hashlib import sha256
from pathlib import Path
from typing import TypedDict
logger = logging.getLogger(__name__)
APPROVAL_MODE_NAMESPACE: tuple[str, str] = ("deepagents_code", "approval_mode")
"""Store namespace for per-thread approval-mode control records."""
YOLO_ACKNOWLEDGEMENT_POLICY_VERSION = "2026-07-14"
"""Version of the unrestricted-mode warning that must be acknowledged."""
class ApprovalMode(StrEnum):
"""Tool-approval policy selected for an interactive thread."""
MANUAL = "manual"
AUTO = "auto"
YOLO = "yolo"
class ApprovalModePayload(TypedDict):
"""Stored approval-mode control payload."""
mode: str
def coerce_approval_mode(value: object) -> ApprovalMode:
"""Return a validated mode, failing closed to `manual`.
Args:
value: Untrusted mode value from config, context, or storage.
Returns:
A validated `ApprovalMode`; invalid values become `ApprovalMode.MANUAL`.
"""
try:
return ApprovalMode(value) if isinstance(value, str) else ApprovalMode.MANUAL
except ValueError:
return ApprovalMode.MANUAL
def approval_mode_key(thread_id: str) -> str:
"""Return the store key for a thread's live approval mode.
Args:
thread_id: LangGraph thread id for the active session.
Returns:
Deterministic store key that does not expose the raw thread id.
"""
return sha256(thread_id.encode("utf-8")).hexdigest()
def approval_mode_payload(
*,
mode: ApprovalMode | str | None = None,
auto_approve: bool | None = None,
) -> ApprovalModePayload:
"""Return the stored approval-mode payload.
Args:
mode: Explicit approval mode.
auto_approve: Compatibility input for callers using the previous Boolean
API. `True` maps to unrestricted `yolo`, and `False` maps to `manual`.
Returns:
JSON-serializable store value.
Raises:
ValueError: If neither or both inputs are supplied, or `mode` is invalid.
"""
if (mode is None) == (auto_approve is None):
msg = "Provide exactly one of mode or auto_approve"
raise ValueError(msg)
if auto_approve is not None:
resolved = ApprovalMode.YOLO if auto_approve else ApprovalMode.MANUAL
else:
try:
resolved = ApprovalMode(mode)
except (TypeError, ValueError) as exc:
msg = f"Invalid approval mode: {mode!r}"
raise ValueError(msg) from exc
return {"mode": resolved.value}
def _item_value(item: object) -> object:
"""Extract a store item's value.
Args:
item: SDK or runtime store-item shape.
Returns:
The stored value, or `None` when the shape is unrecognized.
"""
if isinstance(item, Mapping):
return item.get("value")
return getattr(item, "value", None)
def _approval_mode_from_item(item: object) -> ApprovalMode | None:
"""Extract a validated approval mode from a Store item.
Args:
item: SDK or runtime store-item shape.
Returns:
The stored mode, or `None` when the item is missing or malformed.
"""
if item is None:
logger.debug("Approval-mode store item is missing")
return None
value = _item_value(item)
raw_mode = value.get("mode") if isinstance(value, Mapping) else None
if isinstance(raw_mode, str):
try:
return ApprovalMode(raw_mode)
except ValueError:
pass
logger.warning("Approval-mode store item has invalid contents")
return None
def read_approval_mode_from_store(
store: object, key: str | None
) -> ApprovalMode | None:
"""Read a live approval mode from the server-side LangGraph Store.
Args:
store: `request.runtime.store` from the graph server.
key: Store key produced by `approval_mode_key`.
Returns:
A validated mode, or `None` when the record cannot be trusted. Callers
must interpret `None` as `manual`.
"""
if store is None:
logger.debug("Approval-mode store is unavailable")
return None
if not isinstance(key, str) or not key:
logger.debug("Approval-mode store key is missing or invalid")
return None
get = getattr(store, "get", None)
if get is None:
logger.debug("Approval-mode store does not expose get()")
return None
try:
item = get(APPROVAL_MODE_NAMESPACE, key)
except Exception:
logger.warning("Could not read approval-mode store item", exc_info=True)
return None
return _approval_mode_from_item(item)
async def aread_approval_mode_from_store(
store: object, key: str | None
) -> ApprovalMode | None:
"""Asynchronously read a live approval mode from a LangGraph Store.
The graph server supplies an async batched Store whose synchronous methods
reject calls from the event-loop thread. Prefer `aget()` for that runtime,
while retaining a synchronous fallback for lightweight local test stores.
Args:
store: `request.runtime.store` from the graph server.
key: Store key produced by `approval_mode_key`.
Returns:
A validated mode, or `None` when the record cannot be trusted. Callers
must interpret `None` as `manual`.
"""
if store is None:
logger.debug("Approval-mode store is unavailable")
return None
if not isinstance(key, str) or not key:
logger.debug("Approval-mode store key is missing or invalid")
return None
aget = getattr(store, "aget", None)
get = getattr(store, "get", None)
try:
if callable(aget):
result = aget(APPROVAL_MODE_NAMESPACE, key)
item = await result if inspect.isawaitable(result) else result
elif callable(get):
item = get(APPROVAL_MODE_NAMESPACE, key)
else:
logger.debug("Approval-mode store does not expose get() or aget()")
return None
except Exception:
logger.warning("Could not read approval-mode store item", exc_info=True)
return None
return _approval_mode_from_item(item)
async def awrite_approval_mode(
agent: object,
thread_id: str,
*,
mode: ApprovalMode | str | None = None,
auto_approve: bool | None = None,
) -> str | None:
"""Persist approval mode through an agent's remote store client.
Args:
agent: Agent object. Remote agents expose `aput_store_item`.
thread_id: LangGraph thread id for the active session.
mode: Explicit approval mode.
auto_approve: Compatibility input for the previous Boolean API.
Returns:
Store key written, or `None` when the agent has no store writer.
"""
put = getattr(agent, "aput_store_item", None)
if put is None:
return None
key = approval_mode_key(thread_id)
await put(
APPROVAL_MODE_NAMESPACE,
key,
approval_mode_payload(mode=mode, auto_approve=auto_approve),
)
return key
def yolo_acknowledgement_path() -> Path:
"""Return the installation-local acknowledgement file path.
Returns:
Path under the private dcode state directory.
"""
from deepagents_code.model_config import DEFAULT_STATE_DIR
return DEFAULT_STATE_DIR / "approval.json"
def has_yolo_acknowledgement(path: Path | None = None) -> bool:
"""Return whether the current unrestricted-mode warning was accepted.
Args:
path: Alternate acknowledgement path for tests.
Returns:
`True` only for a valid record matching the current policy version.
"""
target = path or yolo_acknowledgement_path()
try:
data = json.loads(target.read_text(encoding="utf-8"))
except (FileNotFoundError, OSError, UnicodeDecodeError, json.JSONDecodeError):
return False
return (
isinstance(data, dict)
and data.get("version") == 1
and data.get("policy_version") == YOLO_ACKNOWLEDGEMENT_POLICY_VERSION
and data.get("acknowledged") is True
)
def save_yolo_acknowledgement(path: Path | None = None) -> bool:
"""Persist the current unrestricted-mode warning acknowledgement.
Args:
path: Alternate acknowledgement path for tests.
Returns:
`True` when the private atomic write succeeds, otherwise `False`.
"""
target = path or yolo_acknowledgement_path()
payload = {
"version": 1,
"policy_version": YOLO_ACKNOWLEDGEMENT_POLICY_VERSION,
"acknowledged": True,
}
tmp_path: Path | None = None
try:
target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
if os.name != "nt":
target.parent.chmod(0o700)
fd, raw_tmp_path = tempfile.mkstemp(dir=target.parent, suffix=".tmp")
tmp_path = Path(raw_tmp_path)
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(payload, handle, separators=(",", ":"))
handle.write("\n")
if os.name != "nt":
tmp_path.chmod(0o600)
tmp_path.replace(target)
if os.name != "nt":
target.chmod(0o600)
except OSError:
logger.warning("Could not persist YOLO acknowledgement", exc_info=True)
if tmp_path is not None:
with contextlib.suppress(OSError):
tmp_path.unlink()
return False
return True