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
7 changes: 7 additions & 0 deletions REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,3 +182,10 @@ runs need the same confirmation. The agent also keeps a per-project memory file
(`./.deepagents/AGENTS.md`) so it resumes knowing what it was working on. A non-interactive
run (a file/URL source, `--json`, `-o text`, or a non-TTY) has no way to confirm a write or
run, so those are declined there while reads still work.

`--auto-write DIR` relaxes the confirmation for writes inside a chosen subtree: a write or
edit under `DIR` (relative to the launch directory) runs without a keypress, while a write
anywhere else still pauses for approval — handy hands-free, where every confirmation is
friction (e.g. `--auto-write scratch` to let the agent freely save into `./scratch`). Repeat
the flag for several subtrees. It only applies with `--files`, and never auto-approves a
command run (`execute` is always confirmed); the path can't escape the launch directory.
2 changes: 1 addition & 1 deletion aai_cli/AGENTS.md

Large diffs are not rendered by default.

19 changes: 8 additions & 11 deletions aai_cli/agent_cascade/brain.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from aai_cli.agent_cascade.config import CascadeConfig
from aai_cli.agent_cascade.firecrawl_search import WEB_SEARCH_TOOL_NAME
from aai_cli.agent_cascade.prompt import build_system_prompt
from aai_cli.agent_cascade.write_gate import write_interrupt_on
from aai_cli.core import debuglog
from aai_cli.core.errors import CLIError

Expand Down Expand Up @@ -190,12 +191,6 @@ def build_live_tools() -> list[BaseTool]:
return tools


# The mutating tools gated behind human approval when --files is on (reads — incl. grep — stay
# ungated). execute joins the gate because the backend is now sandbox-capable: it runs real
# commands in cwd, OS-confined, but every run is still approved.
_WRITE_TOOLS = ("write_file", "edit_file", "execute")


def _build_fs_backend() -> object:
"""A sandbox-capable deepagents backend rooted at the launch directory.

Expand All @@ -213,18 +208,20 @@ def _graph_kwargs(
) -> dict[str, object]:
"""Extra ``create_deep_agent`` kwargs that turn on real-cwd files + write-gating.

Empty when ``--files`` is off, so the graph is built as before. When on: a real-cwd backend,
``interrupt_on`` gating only the mutating tools, an in-memory checkpointer (interrupt/resume
needs one), and ``backend_factory`` as the test seam. No ``subagents`` key: deepagents
auto-adds a general-purpose subagent that inherits this ``interrupt_on`` (see ``subagents.py``).
Empty when ``--files`` is off, so the graph is built exactly as before. When on: a real-cwd
backend, a path-scoped ``interrupt_on`` (writes outside the ``--auto-write`` subtrees pause
for approval; ``execute`` always does — see :func:`write_gate.write_interrupt_on`), and an
in-memory checkpointer (interrupt/resume needs one). ``backend_factory`` is the test seam. No
``subagents`` key: deepagents auto-adds a general-purpose subagent that inherits this
``interrupt_on`` (see ``subagents.py``).
"""
if not config.files:
return {}
from langgraph.checkpoint.memory import InMemorySaver

return {
"backend": backend_factory(),
"interrupt_on": dict.fromkeys(_WRITE_TOOLS, True),
"interrupt_on": write_interrupt_on(config.auto_write_paths),
"checkpointer": InMemorySaver(),
"memory": ["./.deepagents/AGENTS.md"],
}
Expand Down
6 changes: 6 additions & 0 deletions aai_cli/agent_cascade/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ class CascadeConfig:
# behavior unchanged (the default in-memory backend, no gating, nothing advertised); on
# swaps to a real-cwd FilesystemBackend and gates writes behind human approval.
files: bool = False
# Auto-approve writes under these cwd-rooted virtual subtrees (e.g. ("/scratch",)) when
# --files is on. A write inside one runs without a confirmation keypress; a write anywhere
# else still pauses for approval. Empty (the default) gates every write, as before. The
# paths are normalized virtual roots (always leading "/", no ".."), matching the virtual_mode
# backend the model addresses. execute is never auto-approved — it can't be path-scoped.
auto_write_paths: tuple[str, ...] = ()
# The launch directory's AGENTS.md/CLAUDE.md, read into the system prompt so the agent
# answers grounded in the project it's run from (None when no instruction file is present).
project_context: str | None = None
8 changes: 5 additions & 3 deletions aai_cli/agent_cascade/subagents.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,11 @@ def register_gp_subagent_profile() -> None:

Registers a harness profile that swaps in a spoken-length summary prompt (instead of
deepagents' "complete answer" default) and our short description. The subagent keeps
inheriting the gateway-bound model, the sandboxed toolset, and the top-level ``interrupt_on``.
Idempotent — re-registers the same profile under the same key; ``brain.build_graph`` calls it
once per graph build (the deepagents import stays lazy here, off the startup path).
inheriting the gateway-bound model, the sandboxed toolset, and the top-level ``interrupt_on``
(now path-scoped by ``--auto-write`` — so a delegated write inside an auto-write subtree
auto-approves and any other delegated write still surfaces at the parent gate). Idempotent —
re-registers the same profile under the same key; ``brain.build_graph`` calls it once per
graph build (the deepagents import stays lazy here, off the startup path).
"""
from deepagents import (
GeneralPurposeSubagentProfile,
Expand Down
101 changes: 101 additions & 0 deletions aai_cli/agent_cascade/write_gate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""Path-scoped write gating for ``assembly live --files``.

The ``--files`` brain confirms writes before they touch disk. Rather than gate *every* write
(all-or-nothing, a keypress per write — friction for a hands-free voice turn), a write inside an
``--auto-write`` subtree runs ungated while any other write still pauses for approval — the
allow/interrupt permission model, scoped by path.

This is wired by handing ``HumanInTheLoopMiddleware`` an :class:`InterruptOnConfig` whose ``when``
predicate fires only for writes *outside* every ``--auto-write`` root, so the file-write tools
pause exactly when the target isn't pre-approved. The matching is a transparent posix path-prefix
check (``--auto-write DIR`` is inherently a directory subtree); reads stay ungated and are never
gated here, so no glob/bulk-tool machinery is needed. ``execute`` can't be path-scoped — a command
isn't a single file path — so it stays unconditionally gated: every run is still approved.
"""

from __future__ import annotations

from collections.abc import Callable, Sequence
from pathlib import PurePosixPath
from typing import TYPE_CHECKING, Literal

if TYPE_CHECKING:
from langchain.tools.tool_node import ToolCallRequest

# The file-mutating tools gated behind human approval when --files is on (reads — incl. grep —
# stay ungated). These take an exact file path, so they are path-scopable: a write under an
# --auto-write subtree skips the gate; a write elsewhere still pauses. execute is gated separately
# (always), since a command isn't a single file path to scope.
_FILE_WRITE_TOOLS = ("write_file", "edit_file")

# Offered to the approver on a gated write, matching deepagents' default for a HITL-gated tool.
# Annotated with the Literal decision types so it satisfies InterruptOnConfig.allowed_decisions.
_ALLOWED_DECISIONS: list[Literal["approve", "edit", "reject", "respond"]] = [
"approve",
"edit",
"reject",
"respond",
]


def _normalize_target(raw: str) -> tuple[str, ...] | None:
"""The path's ``/``-rooted virtual segments, or ``None`` if it can't be safely localized.

The model addresses files at ``/``-rooted virtual paths under cwd; a relative path is rooted
the same way deepagents would. A ``..`` segment can't be statically placed (and the backend
blocks the traversal anyway), so it returns ``None`` — the caller then fails safe to gating.
"""
posix = raw.replace("\\", "/")
if not posix.startswith("/"):
posix = "/" + posix
parts = PurePosixPath(posix).parts
if ".." in parts:
return None
return parts


def _under_auto_write(target: tuple[str, ...], roots: Sequence[str]) -> bool:
"""Whether ``target`` (normalized segments) sits in or under one of the ``--auto-write`` roots.

Compares whole path segments (not a string prefix) so ``/scratchpad`` is *not* treated as
under the root ``/scratch`` — a string-prefix check would wrongly auto-approve it.
"""
for root in roots:
root_parts = PurePosixPath(root).parts
if target[: len(root_parts)] == root_parts:
return True
return False


def _auto_write_gate(auto_write_paths: Sequence[str]) -> Callable[[ToolCallRequest], bool]:
"""A ``when`` predicate: fire the approval interrupt unless the write lands in an auto-write
subtree. With no auto-write paths nothing is under one, so every write fires — the prior
all-or-nothing behavior. A non-string or unlocatable (``..``) path fails safe to firing."""

def when(request: ToolCallRequest) -> bool:
raw = request.tool_call.get("args", {}).get("file_path")
if not isinstance(raw, str):
return True
target = _normalize_target(raw)
if target is None:
return True
return not _under_auto_write(target, auto_write_paths)

return when


def write_interrupt_on(auto_write_paths: Sequence[str]) -> dict[str, object]:
"""The ``interrupt_on`` map gating writes for --files, path-scoped by ``--auto-write``.

``write_file``/``edit_file`` share one path-scoped :class:`InterruptOnConfig` (pause only
outside the auto-write subtrees); ``execute`` stays a plain ``True`` so every command run is
approved. The map is handed to ``create_deep_agent(interrupt_on=…)`` and to the subagent spec.
"""
from langchain.agents.middleware import InterruptOnConfig

gate = InterruptOnConfig(
allowed_decisions=_ALLOWED_DECISIONS, when=_auto_write_gate(auto_write_paths)
)
interrupt_on: dict[str, object] = dict.fromkeys(_FILE_WRITE_TOOLS, gate)
interrupt_on["execute"] = True
return interrupt_on
7 changes: 7 additions & 0 deletions aai_cli/commands/agent_cascade/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,12 @@ def live(
help="Let the agent read, write, and run code in the current directory, sandboxed (writes and runs need confirmation). Use --no-files to disable",
rich_help_panel=_PANEL_TOOLS,
),
auto_write: list[str] | None = typer.Option(
None,
"--auto-write",
help="Auto-approve --files writes under this subdirectory, skipping the confirmation (repeatable)",
rich_help_panel=_PANEL_TOOLS,
),
device: int | None = typer.Option(None, "--device", help="Microphone device index"),
list_voices: bool = typer.Option(False, "--list-voices", help="Print known voices and exit"),
json_out: bool = options.json_option("Emit newline-delimited JSON events"),
Expand Down Expand Up @@ -243,6 +249,7 @@ def live(
tts_config=tuple(tts_config or ()),
mcp_config=tuple(mcp_config or ()),
files=files,
auto_write=tuple(auto_write or ()),
show_code=show_code,
)
run_with_options(ctx, agent_cascade_exec.run_agent_cascade, opts, json=json_out)
44 changes: 43 additions & 1 deletion aai_cli/commands/agent_cascade/_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import contextlib
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from pathlib import Path
from pathlib import Path, PurePosixPath
from typing import TYPE_CHECKING

import typer
Expand Down Expand Up @@ -78,6 +78,9 @@ class AgentCascadeOptions:
mcp_config: tuple[Path, ...]
# Let the agent read/write files in the launch directory (writes confirmed; off by default).
files: bool
# Directories (relative to cwd) whose writes auto-approve under --files; everything else
# still pauses for confirmation. Empty gates every write.
auto_write: tuple[str, ...]
# Print the equivalent Python instead of running a conversation.
show_code: bool

Expand Down Expand Up @@ -152,6 +155,42 @@ def _deny_writes(name: str, args: dict[str, object]) -> bool:
return False


def _normalize_auto_write(raw: str) -> str:
"""Normalize one ``--auto-write DIR`` to a cwd-rooted virtual prefix (``scratch`` -> ``/scratch``).

The ``--files`` backend runs in ``virtual_mode``, so the model addresses files at ``/``-rooted
virtual paths mapped under cwd; an auto-approve subtree is expressed against that same virtual
root. Leading ``./`` and ``/`` are stripped and a single ``/`` re-added, so ``scratch``,
``./scratch``, and ``/scratch`` all resolve to ``/scratch``. Traversal (``..``), home (``~``),
and the whole-tree aliases (empty, ``.``, ``/``) are rejected — an auto-write path must name a
contained subtree, never escape cwd or silently disable every write gate.
"""
cleaned = raw.strip().replace("\\", "/").strip("/")
while cleaned.startswith("./"):
cleaned = cleaned[2:].strip("/")
parts = PurePosixPath(cleaned).parts if cleaned else ()
if not cleaned or cleaned == "." or ".." in parts or "~" in parts:
raise UsageError(
f"--auto-write expects a subdirectory under the launch directory, got {raw!r}.",
suggestion="e.g. --auto-write scratch",
)
return "/" + cleaned


def _resolve_auto_write_paths(*, auto_write: tuple[str, ...], files: bool) -> tuple[str, ...]:
"""Normalize every ``--auto-write`` value to a virtual root; reject it without ``--files``.

``--auto-write`` only relaxes the ``--files`` write gate, so passing it alone is a usage
error (a silent no-op would hide the missing ``--files``).
"""
if auto_write and not files:
raise UsageError(
"--auto-write only applies with --files.",
suggestion="add --files to enable filesystem access",
)
return tuple(_normalize_auto_write(value) for value in auto_write)


def _resolve_mcp_servers(mcp_config: tuple[Path, ...]) -> dict[str, Mapping[str, object]]:
"""The MCP servers for this run: only those from ``--mcp-config`` files (none by default).

Expand Down Expand Up @@ -317,6 +356,8 @@ def run_agent_cascade(opts: AgentCascadeOptions, state: AppState, *, json_mode:
tts_extra = _parse_tts_config(opts.tts_config)
# Resolve MCP servers before opening the device, so a malformed config fails fast.
mcp_servers = _resolve_mcp_servers(opts.mcp_config)
# Normalize --auto-write subtrees (and reject them without --files) before the device opens.
auto_write_paths = _resolve_auto_write_paths(auto_write=opts.auto_write, files=opts.files)
api_key = state.resolve_api_key()

config = CascadeConfig(
Expand All @@ -332,6 +373,7 @@ def run_agent_cascade(opts: AgentCascadeOptions, state: AppState, *, json_mode:
tts_extra=tts_extra,
mcp_servers=mcp_servers,
files=opts.files,
auto_write_paths=auto_write_paths,
# Read the launch directory's AGENTS.md/CLAUDE.md into context, so the agent answers
# grounded in the project it's run from (like a coding agent).
project_context=load_project_context(),
Expand Down
3 changes: 3 additions & 0 deletions tests/__snapshots__/test_snapshots_help_run.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,9 @@
│ (writes and runs need confirmation). Use │
│ --no-files to disable │
│ [default: files] │
│ --auto-write TEXT Auto-approve --files writes under this │
│ subdirectory, skipping the confirmation │
│ (repeatable) │
╰──────────────────────────────────────────────────────────────────────────────╯

Examples
Expand Down
Loading
Loading