diff --git a/REFERENCE.md b/REFERENCE.md index df511524..04a4341a 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -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. diff --git a/aai_cli/AGENTS.md b/aai_cli/AGENTS.md index 1074a542..6933547e 100644 --- a/aai_cli/AGENTS.md +++ b/aai_cli/AGENTS.md @@ -153,7 +153,7 @@ heavily-reworked commands with long bodies; small commands keep the inline - **`streaming/`** + `client.stream_audio` — v3 realtime API. Event callbacks run on the SDK reader thread and guard against `BrokenPipeError` (`stdio.silence_stdout()`) so a closed pipe never dumps a thread traceback. - **`core/sync_stt.py`** + **`core/signals.py`** + `commands/dictate/` — `assembly dictate`: headless dictation over the **Sync STT API** (`Environment.sync_base`, one POST `/transcribe` per utterance with the required `X-AAI-Model: u3-sync-pro` header; 80 ms–120 s of PCM/WAV). It needs no terminal: recording starts immediately and `dictate_exec._record` polls `signals.stop_on_terminate` between ~100 ms mic chunks for a SIGTERM, which finishes the utterance (clean exit 0) — so a hotkey tool like Hammerspoon can launch it as a background task and `kill -TERM`/`task:terminate()` to transcribe. SIGINT (Ctrl-C) still cancels (exit 130). Both boundaries (the stop latch, mic, HTTP) are injectable, so the suite never needs a real signal or microphone (`tests/test_dictate_exec.py` scripts the SIGTERM latch). Contrast `signals.terminate_as_interrupt` (used by `stream`/`agent`/`speak`), which routes SIGTERM into the *cancel* path instead. - **`agent/`** — full-duplex voice agent (mic in, TTS out via `voices.py`). -- **`agent_cascade/`** + `commands/agent_cascade/` — `assembly agent-cascade`: the same live terminal conversation as `assembly agent`, but **client-orchestrated** — `engine.run_cascade` wires Streaming STT → the LLM Gateway → streaming TTS itself instead of talking to the Voice Agent endpoint, mirroring what the `agent-cascade` `assembly init` template does server-side. **Sandbox-only** (streaming TTS has no prod host; guarded via `tts.session.require_available`). Reuses the agent slice's `DuplexAudio`/`AgentRenderer` and `core.client.stream_audio`/`core.llm.complete`/`tts.session.synthesize`; the three network legs are injected through `engine.CascadeDeps` (the `tts/session.py` seam) so the cascade — greeting, clause-level streaming TTS, barge-in — is unit-tested against fakes with no sockets/mic/speaker. The LLM leg is a deepagents graph (`brain.py`) streamed token-by-token via `brain.build_streamer` (`graph.stream(stream_mode="messages")`): **context-window management is the brain's job, not the engine's** — `create_deep_agent` wires deepagents' own `SummarizationMiddleware` into the stack (summarize the oldest turns, offload the evicted history to a file), so the engine feeds the *full* untrimmed running history each turn and lets the graph compact it; the old client-side `text.trim_history`/`config.max_history` sliding window is gone from this path (`max_history` now only drives the hand-rolled `--show-code`/`assembly init` cascade, which doesn't use deepagents). The engine buffers `SpeechDelta`s, flushes complete clauses with `text.pop_clauses` (soft-separator clauses gated by `engine._MIN_CLAUSE_CHARS`), and synthesizes each clause with **streaming TTS** (`tts.session.synthesize(on_audio=…)`) so audio starts on the first frame instead of after the whole reply. The reply runs on a throwaway producer thread feeding a `queue.Queue` the worker drains under a monotonic deadline (the wall-clock backstop that replaced `_complete_within`), and an abandoned-on-timeout graph leg's langchain `ThreadPoolExecutor` worker is detached (`_detach_executor_threads_since`) so it can't wedge interpreter exit. A `ToolNotice` surfaces the "Searching the web…" affordance and drops any unspoken preamble. Under `-v` (`debuglog.active()`) `brain._stream_graph` logs each accumulated assistant line, tool call, and tool result as it streams. **Front-end:** an interactive mic session in human mode runs a **voice-only Textual TUI** (`agent_cascade/tui.py`, `LiveAgentApp`) by default — there's no text input (you can't type to it), just a transcript + an animated voice bar tracking listening/thinking/speaking. It uses its own `banner` wordmark, `messages` widgets, and `tui_status.voicebar_markup`/`VOICE_FRAMES` — all modules that now live in `agent_cascade/`; the blocking `run_cascade` runs on a worker thread and reaches the UI through a `_TuiRenderer` (the `engine.Renderer` protocol) that hops each call onto the UI thread, and a quit calls `DuplexAudio.close` to end the mic iterator and unblock that worker. `_exec._should_use_tui` gates it: file/sample input, `--json`/`-o text`, and a non-TTY all fall back to the plain `AgentRenderer` line output. **`--files`** (on by default; `--no-files` opts out) swaps the brain's in-memory backend for a real-cwd, sandbox-capable `SandboxedShellBackend` (`aai_cli/agent_cascade/sandbox.py`): file ops behave as before (traversal-blocked `virtual_mode`), and because it implements `SandboxBackendProtocol` deepagents binds a *functional* `execute` that runs commands OS-sandboxed in the real cwd — `sandbox-exec` (SBPL) on macOS, `bwrap` on Linux, refused (never an unconfined fallback) on any other platform or with the sandbox binary missing; the OS sandbox blocks the network, confines writes to cwd (+ the temp dir), and read-denies credential stores (`~/.ssh`/`~/.aws`/…, `.env*`, `.claude/`). The policy renderers are pure and the subprocess/capability boundaries injected, so the suite asserts *what we'd run* with no real sandbox. `write_file`/`edit_file`/`execute` are gated via `interrupt_on` + an `InMemorySaver`; `brain._stream_gated` detects the post-stream interrupt (`graph.get_state(config).interrupts`), asks an injected `Approver`, and resumes with `Command(resume=…)`, bracketing the human wait in `ApprovalPause` events so `engine._consume` suspends its reply deadline (`risk.py` surfaces a shell-risk warning on the prompt). The voice TUI supplies the approver via `agent_cascade.modals.ApprovalScreen` (`y`/`a`/`n`), which can *also* be resolved hands-free by voice: while a write awaits approval, `_consume` arms `_awaiting_approval` and `engine.on_turn` routes the next final transcript to `app.submit_voice_approval` → `ApprovalScreen.try_voice`, which applies `spoken_approval.spoken_decision` (an unambiguous affirmative approves, anything else rejects — fail-safe; destructive `risk.py`-flagged commands ignore the spoken answer and require a keypress). **Project grounding (independent of `--files`):** `_exec.run_agent_cascade` reads the launch directory's `AGENTS.md`/`CLAUDE.md` via `agent_cascade/project_context.load_project_context()` into `CascadeConfig.project_context`, which `brain.build_graph` threads into `prompt.build_system_prompt(..., project_context=…)` (appended as project background after the persona/tool guidance). `AGENTS.md` wins precedence, identical content (a symlinked `CLAUDE.md`) is de-duplicated, and the total is capped at `project_context.MAX_CONTEXT_CHARS`. It's read at the command boundary (not in `build_graph`) so the brain stays hermetic, and the `--show-code` path builds its own config without it. Headless runs auto-deny (`_exec._deny_writes`). `--files` also turns on durable per-project memory via deepagents' `MemoryMiddleware` (`memory=["./.deepagents/AGENTS.md"]`), distinct from the in-session `InMemorySaver`. The gateway-bound, sandbox-backed general-purpose subagent (deepagents' `task` tool) for delegating a focused subtask is **auto-added by deepagents** — we don't declare it. We only override its prose for a voice turn (a spoken-length summary, not the SDK's "complete answer" default) via a harness profile keyed by the gateway model's provider (`subagents.register_gp_subagent_profile`, called from `build_graph` so the deepagents import stays lazy — and kept off `brain.py`, which sits at the 500-line gate). It inherits the gateway-bound model, the sandboxed toolset, *and* the top-level `interrupt_on` (deepagents' `graph.py` merges the top-level config into the auto-added subagent), so a delegated `write_file`/`edit_file`/`execute` surfaces at the *parent* `get_state().interrupts` with no per-subagent restatement (so `_pending_writes` gates it too — verified by a HITL spike, locked in `tests/test_agent_cascade_subagents.py`). Reads (incl. `grep`) stay ungated. +- **`agent_cascade/`** + `commands/agent_cascade/` — `assembly agent-cascade`: the same live terminal conversation as `assembly agent`, but **client-orchestrated** — `engine.run_cascade` wires Streaming STT → the LLM Gateway → streaming TTS itself instead of talking to the Voice Agent endpoint, mirroring what the `agent-cascade` `assembly init` template does server-side. **Sandbox-only** (streaming TTS has no prod host; guarded via `tts.session.require_available`). Reuses the agent slice's `DuplexAudio`/`AgentRenderer` and `core.client.stream_audio`/`core.llm.complete`/`tts.session.synthesize`; the three network legs are injected through `engine.CascadeDeps` (the `tts/session.py` seam) so the cascade — greeting, clause-level streaming TTS, barge-in — is unit-tested against fakes with no sockets/mic/speaker. The LLM leg is a deepagents graph (`brain.py`) streamed token-by-token via `brain.build_streamer` (`graph.stream(stream_mode="messages")`): **context-window management is the brain's job, not the engine's** — `create_deep_agent` wires deepagents' own `SummarizationMiddleware` into the stack (summarize the oldest turns, offload the evicted history to a file), so the engine feeds the *full* untrimmed running history each turn and lets the graph compact it; the old client-side `text.trim_history`/`config.max_history` sliding window is gone from this path (`max_history` now only drives the hand-rolled `--show-code`/`assembly init` cascade, which doesn't use deepagents). The engine buffers `SpeechDelta`s, flushes complete clauses with `text.pop_clauses` (soft-separator clauses gated by `engine._MIN_CLAUSE_CHARS`), and synthesizes each clause with **streaming TTS** (`tts.session.synthesize(on_audio=…)`) so audio starts on the first frame instead of after the whole reply. The reply runs on a throwaway producer thread feeding a `queue.Queue` the worker drains under a monotonic deadline (the wall-clock backstop that replaced `_complete_within`), and an abandoned-on-timeout graph leg's langchain `ThreadPoolExecutor` worker is detached (`_detach_executor_threads_since`) so it can't wedge interpreter exit. A `ToolNotice` surfaces the "Searching the web…" affordance and drops any unspoken preamble. Under `-v` (`debuglog.active()`) `brain._stream_graph` logs each accumulated assistant line, tool call, and tool result as it streams. **Front-end:** an interactive mic session in human mode runs a **voice-only Textual TUI** (`agent_cascade/tui.py`, `LiveAgentApp`) by default — there's no text input (you can't type to it), just a transcript + an animated voice bar tracking listening/thinking/speaking. It uses its own `banner` wordmark, `messages` widgets, and `tui_status.voicebar_markup`/`VOICE_FRAMES` — all modules that now live in `agent_cascade/`; the blocking `run_cascade` runs on a worker thread and reaches the UI through a `_TuiRenderer` (the `engine.Renderer` protocol) that hops each call onto the UI thread, and a quit calls `DuplexAudio.close` to end the mic iterator and unblock that worker. `_exec._should_use_tui` gates it: file/sample input, `--json`/`-o text`, and a non-TTY all fall back to the plain `AgentRenderer` line output. **`--files`** (on by default; `--no-files` opts out) swaps the brain's in-memory backend for a real-cwd, sandbox-capable `SandboxedShellBackend` (`aai_cli/agent_cascade/sandbox.py`): file ops behave as before (traversal-blocked `virtual_mode`), and because it implements `SandboxBackendProtocol` deepagents binds a *functional* `execute` that runs commands OS-sandboxed in the real cwd — `sandbox-exec` (SBPL) on macOS, `bwrap` on Linux, refused (never an unconfined fallback) on any other platform or with the sandbox binary missing; the OS sandbox blocks the network, confines writes to cwd (+ the temp dir), and read-denies credential stores (`~/.ssh`/`~/.aws`/…, `.env*`, `.claude/`). The policy renderers are pure and the subprocess/capability boundaries injected, so the suite asserts *what we'd run* with no real sandbox. `write_file`/`edit_file`/`execute` are gated via `interrupt_on` + an `InMemorySaver`. The write gate is **path-scoped (the allow/interrupt permission model), not all-or-nothing**: `write_gate.write_interrupt_on(config.auto_write_paths)` hands `write_file`/`edit_file` one `InterruptOnConfig` whose `when` predicate fires only for writes *outside* every `--auto-write` subtree, so a write inside one runs ungated while others still pause (empty paths gate every write, the prior behavior). The matching is a transparent posix path-segment prefix check — `--auto-write DIR` is a directory subtree, and reads stay ungated, so no glob/bulk-tool machinery is needed (and no reach into deepagents internals: `create_deep_agent(permissions=…)` is unusable here — it raises `NotImplementedError` against an execute-capable backend like our `SandboxedShellBackend`). A non-string or `..`-containing target fails safe to gating. `execute` stays a plain `True` (unconditionally gated) — a command isn't a single file path to scope. `--auto-write DIR` is normalized to a cwd-rooted virtual root in `_exec._normalize_auto_write` (rejecting `..`/`~`/whole-tree aliases) and requires `--files`. `brain._stream_gated` detects the post-stream interrupt (`graph.get_state(config).interrupts`), asks an injected `Approver`, and resumes with `Command(resume=…)`, bracketing the human wait in `ApprovalPause` events so `engine._consume` suspends its reply deadline (`risk.py` surfaces a shell-risk warning on the prompt). The voice TUI supplies the approver via `agent_cascade.modals.ApprovalScreen` (`y`/`a`/`n`), which can *also* be resolved hands-free by voice: while a write awaits approval, `_consume` arms `_awaiting_approval` and `engine.on_turn` routes the next final transcript to `app.submit_voice_approval` → `ApprovalScreen.try_voice`, which applies `spoken_approval.spoken_decision` (an unambiguous affirmative approves, anything else rejects — fail-safe; destructive `risk.py`-flagged commands ignore the spoken answer and require a keypress). **Project grounding (independent of `--files`):** `_exec.run_agent_cascade` reads the launch directory's `AGENTS.md`/`CLAUDE.md` via `agent_cascade/project_context.load_project_context()` into `CascadeConfig.project_context`, which `brain.build_graph` threads into `prompt.build_system_prompt(..., project_context=…)` (appended as project background after the persona/tool guidance). `AGENTS.md` wins precedence, identical content (a symlinked `CLAUDE.md`) is de-duplicated, and the total is capped at `project_context.MAX_CONTEXT_CHARS`. It's read at the command boundary (not in `build_graph`) so the brain stays hermetic, and the `--show-code` path builds its own config without it. Headless runs auto-deny (`_exec._deny_writes`). `--files` also turns on durable per-project memory via deepagents' `MemoryMiddleware` (`memory=["./.deepagents/AGENTS.md"]`), distinct from the in-session `InMemorySaver`. The gateway-bound, sandbox-backed general-purpose subagent (deepagents' `task` tool) for delegating a focused subtask is **auto-added by deepagents** — we don't declare it. We only override its prose for a voice turn (a spoken-length summary, not the SDK's "complete answer" default) via a harness profile keyed by the gateway model's provider (`subagents.register_gp_subagent_profile`, called from `build_graph` so the deepagents import stays lazy — and kept off `brain.py`, which sits at the 500-line gate). It inherits the gateway-bound model, the sandboxed toolset, *and* the top-level `interrupt_on` (deepagents' `graph.py` merges the top-level config into the auto-added subagent), so a delegated `write_file`/`edit_file`/`execute` surfaces at the *parent* `get_state().interrupts` with no per-subagent restatement (so `_pending_writes` gates it too — verified by a HITL spike, locked in `tests/test_agent_cascade_subagents.py`). Reads (incl. `grep`) stay ungated. - **`tts/`** + `commands/speak.py` — `assembly speak` synthesizes text to speech over the sandbox streaming-TTS WebSocket (`streaming-tts.sandbox000.…`). **Sandbox-only:** `session.is_available()` is false in production (empty `Environment.streaming_tts_host`), so the command exits 2 with a `--sandbox` hint. `session.synthesize` drives a Begin→Generate→Flush→Audio→Terminate protocol with an injectable `connect` for hermetic tests (mirrors `agent/session.py`); `audio.py` plays the PCM (default) or writes a WAV (`--out`). The single-voice default-playback path **streams**: `synthesize`'s `on_audio(chunk, sample_rate)` callback is wired to `audio.PcmPlayer.feed`, so speech starts on the first Audio frame (it opens the device lazily, since the rate is only known at Begin) instead of after the whole text — the win for a long `--url` page. `--out` (needs the full buffer) and the multi-voice dialogue path (`synthesize_dialogue` → `_output_audio` → buffered `play_pcm`) stay buffered; `synthesize` still returns the complete PCM for the summary regardless. - **`code_gen/`** — backs `--show-code` on `transcribe`/`stream`/`agent`: builds a ready-to-run Python SDK script from exactly the flags passed (no API key needed; generated code reads `ASSEMBLYAI_API_KEY`). - **`auth/`** — browser-assisted `assembly login` via AMS + **Stytch B2B OAuth discovery** (`discovery.py`, `flow.py`, `loopback.py`, `ams.py`). Not Stytch Connected Apps. diff --git a/aai_cli/agent_cascade/brain.py b/aai_cli/agent_cascade/brain.py index c1172b00..8f5e574f 100644 --- a/aai_cli/agent_cascade/brain.py +++ b/aai_cli/agent_cascade/brain.py @@ -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 @@ -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. @@ -213,10 +208,12 @@ 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 {} @@ -224,7 +221,7 @@ def _graph_kwargs( 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"], } diff --git a/aai_cli/agent_cascade/config.py b/aai_cli/agent_cascade/config.py index fd038d89..2655164a 100644 --- a/aai_cli/agent_cascade/config.py +++ b/aai_cli/agent_cascade/config.py @@ -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 diff --git a/aai_cli/agent_cascade/subagents.py b/aai_cli/agent_cascade/subagents.py index 2eeb5dae..f4e4b8db 100644 --- a/aai_cli/agent_cascade/subagents.py +++ b/aai_cli/agent_cascade/subagents.py @@ -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, diff --git a/aai_cli/agent_cascade/write_gate.py b/aai_cli/agent_cascade/write_gate.py new file mode 100644 index 00000000..cecb2245 --- /dev/null +++ b/aai_cli/agent_cascade/write_gate.py @@ -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 diff --git a/aai_cli/commands/agent_cascade/__init__.py b/aai_cli/commands/agent_cascade/__init__.py index 2b377bbf..172e3968 100644 --- a/aai_cli/commands/agent_cascade/__init__.py +++ b/aai_cli/commands/agent_cascade/__init__.py @@ -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"), @@ -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) diff --git a/aai_cli/commands/agent_cascade/_exec.py b/aai_cli/commands/agent_cascade/_exec.py index ff978493..564dc766 100644 --- a/aai_cli/commands/agent_cascade/_exec.py +++ b/aai_cli/commands/agent_cascade/_exec.py @@ -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 @@ -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 @@ -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). @@ -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( @@ -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(), diff --git a/tests/__snapshots__/test_snapshots_help_run.ambr b/tests/__snapshots__/test_snapshots_help_run.ambr index d4971549..be0f68e3 100644 --- a/tests/__snapshots__/test_snapshots_help_run.ambr +++ b/tests/__snapshots__/test_snapshots_help_run.ambr @@ -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 diff --git a/tests/test_agent_cascade_brain.py b/tests/test_agent_cascade_brain.py index 7f83e452..a9ed22bb 100644 --- a/tests/test_agent_cascade_brain.py +++ b/tests/test_agent_cascade_brain.py @@ -12,7 +12,7 @@ from langchain_core.messages import AIMessage -from aai_cli.agent_cascade import brain, datetime_tool, weather_tool, webpage_tool +from aai_cli.agent_cascade import brain, datetime_tool, weather_tool, webpage_tool, write_gate from aai_cli.agent_cascade import model as model_mod from aai_cli.agent_cascade.config import CascadeConfig from tests._cascade_fakes import FakeChatModel @@ -35,11 +35,106 @@ def test_graph_kwargs_gates_writes_and_execute_and_sets_memory(monkeypatch, tmp_ assert isinstance(backend, sandbox.SandboxedShellBackend) assert Path(backend.cwd) == tmp_path.resolve() assert backend.virtual_mode is True - # execute now joins the write gate. - assert kwargs["interrupt_on"] == {"write_file": True, "edit_file": True, "execute": True} + # With no --auto-write paths, the gate covers both file-write tools (path-scoped) plus the + # always-gated execute. write_file/edit_file are now InterruptOnConfig maps (a `when` + # predicate), execute stays a plain True. + interrupt_on = kwargs["interrupt_on"] + assert sorted(interrupt_on) == ["edit_file", "execute", "write_file"] + assert interrupt_on["execute"] is True + assert "when" in interrupt_on["write_file"] assert kwargs["checkpointer"] is not None # Durable per-project memory is turned on. assert kwargs["memory"] == ["./.deepagents/AGENTS.md"] + # No explicit subagent: deepagents auto-adds the general-purpose one and it inherits this + # top-level interrupt_on (the delegated-write surfacing is locked in test_agent_cascade_subagents). + assert "subagents" not in kwargs + + +def test_graph_kwargs_threads_auto_write_paths_into_the_gate(monkeypatch, tmp_path): + # CascadeConfig.auto_write_paths reaches the graph's interrupt_on, so a write under the + # configured subtree auto-approves while others still gate. + monkeypatch.chdir(tmp_path) + kwargs = brain._graph_kwargs(CascadeConfig(files=True, auto_write_paths=("/scratch",))) + interrupt_on = kwargs["interrupt_on"] + assert _write_fires(interrupt_on, "write_file", "/scratch/out.md") is False + assert _write_fires(interrupt_on, "write_file", "/elsewhere.md") is True + + +def _write_fires(interrupt_on: dict[str, object], tool: str, path: str) -> bool: + """Whether the path-scoped gate would pause `tool` for the write to `path`. + + Drives the InterruptOnConfig's `when` predicate directly — its True means "interrupt + (ask the approver)", False means "auto-approve (run ungated)". The predicate reads only + ``req.tool_call`` (the dict of name/args), so a SimpleNamespace stands in for the full + langchain ``ToolCallRequest`` without dragging its strict TypedDict/runtime fields in. + """ + import types + + config = interrupt_on[tool] + assert isinstance(config, dict) + req = types.SimpleNamespace(tool_call={"name": tool, "args": {"file_path": path}}) + return bool(config["when"](req)) + + +def test_write_interrupt_on_gates_every_write_without_auto_write_paths(): + # No --auto-write subtrees: both file-write tools pause for any target (the pre-existing + # all-or-nothing behavior), and execute is always gated. + interrupt_on = write_gate.write_interrupt_on(()) + assert interrupt_on["execute"] is True + assert _write_fires(interrupt_on, "write_file", "/notes.txt") is True + assert _write_fires(interrupt_on, "edit_file", "/src/deep/main.py") is True + + +def test_write_interrupt_on_auto_approves_under_allowed_subtree(): + # A --auto-write subtree relaxes the gate ONLY for writes inside it: a write under /scratch + # runs ungated (when -> False), a write elsewhere still pauses (when -> True). This is the + # crux of the feature, so assert both sides — a one-sided assert would miss an always-allow + # or always-gate mutant. + interrupt_on = write_gate.write_interrupt_on(("/scratch",)) + assert _write_fires(interrupt_on, "write_file", "/scratch/out.md") is False + assert _write_fires(interrupt_on, "edit_file", "/scratch/sub/deep.txt") is False + assert _write_fires(interrupt_on, "write_file", "/notes.txt") is True + assert _write_fires(interrupt_on, "edit_file", "/src/main.py") is True + # execute is never path-scoped — every command run is still approved. + assert interrupt_on["execute"] is True + + +def test_write_interrupt_on_honors_multiple_auto_write_subtrees(): + # Several --auto-write subtrees each auto-approve; an unlisted sibling still gates. + interrupt_on = write_gate.write_interrupt_on(("/scratch", "/build")) + assert _write_fires(interrupt_on, "write_file", "/scratch/a") is False + assert _write_fires(interrupt_on, "write_file", "/build/b") is False + assert _write_fires(interrupt_on, "write_file", "/src/c") is True + + +def test_write_interrupt_on_matches_whole_segments_not_string_prefix(): + # A sibling that merely shares a name prefix (/scratchpad vs the root /scratch) is NOT under + # the auto-write subtree, so it still gates — a naive string-prefix check would wrongly skip it. + interrupt_on = write_gate.write_interrupt_on(("/scratch",)) + assert _write_fires(interrupt_on, "write_file", "/scratchpad/x") is True + assert _write_fires(interrupt_on, "write_file", "/scratch") is False # the root node itself + + +def test_write_interrupt_on_roots_a_relative_target_like_an_absolute_one(): + # A relative tool-call path is rooted under cwd the same way the model's /-rooted paths are, + # so `scratch/out.md` resolves under the /scratch auto-write subtree and auto-approves. + interrupt_on = write_gate.write_interrupt_on(("/scratch",)) + assert _write_fires(interrupt_on, "write_file", "scratch/out.md") is False + assert _write_fires(interrupt_on, "write_file", "other/out.md") is True + + +def test_write_interrupt_on_fails_safe_to_gating_for_unlocatable_paths(): + # A path the gate can't place — a non-string arg, or one with a `..` segment — must require + # approval rather than silently auto-approve, even inside an auto-write subtree. + interrupt_on = write_gate.write_interrupt_on(("/scratch",)) + assert _write_fires(interrupt_on, "write_file", "/scratch/../etc/passwd") is True + # A non-string file_path arg also fails safe (drive the predicate directly). + import types + + config = interrupt_on["write_file"] + assert isinstance(config, dict) + req = types.SimpleNamespace(tool_call={"name": "write_file", "args": {"file_path": 123}}) + assert config["when"](req) is True def test_sandboxed_backend_implements_sandbox_protocol(monkeypatch, tmp_path): diff --git a/tests/test_agent_cascade_command.py b/tests/test_agent_cascade_command.py index 84c6eaee..a514012a 100644 --- a/tests/test_agent_cascade_command.py +++ b/tests/test_agent_cascade_command.py @@ -50,6 +50,7 @@ tts_config=(), mcp_config=(), files=False, + auto_write=(), show_code=False, ) diff --git a/tests/test_agent_cascade_files.py b/tests/test_agent_cascade_files.py index b4bc2557..fe64d0b5 100644 --- a/tests/test_agent_cascade_files.py +++ b/tests/test_agent_cascade_files.py @@ -51,6 +51,57 @@ def test_deny_writes_always_rejects(): assert _exec._deny_writes("edit_file", {"file_path": "/y"}) is False +def test_normalize_auto_write_maps_variants_to_a_virtual_root(): + # A bare name, a ./ prefix, and a leading / all resolve to the same cwd-rooted virtual root; + # a nested subdir keeps its segments. + assert _exec._normalize_auto_write("scratch") == "/scratch" + assert _exec._normalize_auto_write("./scratch") == "/scratch" + assert _exec._normalize_auto_write("/scratch") == "/scratch" + assert _exec._normalize_auto_write("out/data") == "/out/data" + + +@pytest.mark.parametrize("bad", ["", ".", "/", "../escape", "~", "~/secrets", "a/../b"]) +def test_normalize_auto_write_rejects_escapes_and_whole_tree_aliases(bad): + # Traversal, home, and the whole-tree aliases can't name a contained subtree, so each is a + # clean usage error rather than a silently-broadened or gate-disabling path. + from aai_cli.core.errors import UsageError + + with pytest.raises(UsageError): + _exec._normalize_auto_write(bad) + + +def test_resolve_auto_write_paths_requires_files(): + from aai_cli.core.errors import UsageError + + with pytest.raises(UsageError, match="only applies with --files"): + _exec._resolve_auto_write_paths(auto_write=("scratch",), files=False) + # With --files it normalizes every value; empty input stays empty regardless of the flag. + assert _exec._resolve_auto_write_paths(auto_write=("scratch",), files=True) == ("/scratch",) + assert _exec._resolve_auto_write_paths(auto_write=(), files=False) == () + + +def test_auto_write_threads_into_config(monkeypatch): + # --auto-write reaches CascadeConfig.auto_write_paths (normalized) on the headless run path. + monkeypatch.setattr(_exec.tts_session, "require_available", lambda _c: None) + monkeypatch.setattr(config, "resolve_api_key", lambda **_: "k") + monkeypatch.setattr(_exec, "FileSource", lambda src: types.SimpleNamespace(sample_rate=16000)) + monkeypatch.setattr(_exec.client, "resolve_audio_source", lambda source, sample: "clip.wav") + captured = {} + + def fake_real(api_key, cfg, *, audio, stt_params, approver=None): + captured["auto_write_paths"] = cfg.auto_write_paths + return "deps" + + monkeypatch.setattr(_exec.engine.CascadeDeps, "real", fake_real) + monkeypatch.setattr(_exec.engine, "run_cascade", lambda **kwargs: None) + run_agent_cascade( + _opts(source="clip.wav", files=True, auto_write=("scratch", "./build")), + AppState(), + json_mode=False, + ) + assert captured["auto_write_paths"] == ("/scratch", "/build") + + def test_files_flag_threads_into_config_with_deny_approver_on_headless_path(monkeypatch): # --files reaches CascadeConfig.files, and the non-interactive (file source) path wires the # deny-writes approver since there's no keyboard channel to confirm a write. diff --git a/tests/test_agent_cascade_subagents.py b/tests/test_agent_cascade_subagents.py index f0db500a..086dded7 100644 --- a/tests/test_agent_cascade_subagents.py +++ b/tests/test_agent_cascade_subagents.py @@ -67,10 +67,15 @@ def test_profile_override_lands_on_the_auto_added_subagent(monkeypatch, tmp_path def test_graph_kwargs_on_gates_writes_without_declaring_a_subagent(): # --files binds the gating + checkpointer but no explicit subagent: the gateway-bound GP - # subagent is auto-added and inherits this interrupt_on (see the surfacing test below). + # subagent is auto-added and inherits this interrupt_on (see the surfacing test below). The + # write tools are now path-scoped InterruptOnConfig maps (a `when` predicate), execute a plain + # True — the auto-added subagent inherits the whole map, so it honors --auto-write too. kw = brain._graph_kwargs(CascadeConfig(files=True)) assert "subagents" not in kw - assert kw["interrupt_on"] == {"write_file": True, "edit_file": True, "execute": True} + interrupt_on = kw["interrupt_on"] + assert sorted(interrupt_on) == ["edit_file", "execute", "write_file"] + assert interrupt_on["execute"] is True + assert "when" in interrupt_on["write_file"] and "when" in interrupt_on["edit_file"] assert "checkpointer" in kw and "backend" in kw