Agent IDE follows a suggest-then-apply model: the Agent's proposed changes are visible and reviewable before they touch the disk, and the review flow is covered by backend tests rather than by manual clicking.
This document states what the backend actually enforces and what it does not. Claims here were verified by reading the enforcing code; where a protection is missing or partial it is listed as such rather than described aspirationally. The single largest gap is MCP tool exposure — see that section before enabling an MCP server.
All filesystem operations (read/write/delete) are scoped to the open workspace root. The centralized workspace service enforces this boundary:
workspace::resolve_existing— resolves read paths after canonicalization and rejects any path outside the workspace root. Also rejects relative traversal (../../etc/passwd) by canonicalizing the candidate and checking the prefix.workspace::resolve_for_write— resolves write paths, including new files that do not yet exist, by validating the nearest existing ancestor is within the workspace. Rejects writes outside the workspace boundary.workspace::ensure_within_workspace— lower-level guard used by both resolve functions. Compares the canonical path prefix against the workspace root.workspace::shell_compatible_path— normalizes Windows verbatim\\?\D:\...and\\?\UNC\...prefixes into shell-compatible paths, preventing canonicalization mismatches on Windows.
Surfaces that enforce the workspace boundary:
- Filesystem commands (
read_file_content,write_file_content, file tree operations) - Agent-generated diffs (
apply_pending_diffsresolves each target throughresolve_for_agent_write, which adds the deny list below) - Per-file Git path operations (stage, unstage, discard, conflict resolution, per-file commit)
- Terminal cwd (spawned PTY sessions start in the workspace root; Windows
\\?\prefixes are stripped before passing tocmd.exe) - Project task cwd (non-interactive command runner inherits workspace-scoped cwd)
- Agent CLI (shared workspace resolution and boundary checks)
Surfaces that do not fully enforce it:
- Repository-wide Git operations. Every Git command starts with
git2::Repository::discover, which walks upward. If the workspace root is a subdirectory of a larger repository, the repository working directory is an ancestor of the workspace root, and worktree-wide operations —checkout_head(branch checkout, pull, discard) andgit_commitwith no explicit file list, which doesindex.add_all(["*"])— act on files outside the workspace. The git-diff context section is scoped back to the workspace with a pathspec, so it no longer leaks sibling directories to the model, but the write-side operations remain unscoped. - Recursive traversal.
search_recursiveandcopy_dir_recursivecheck the root once and then descend without re-checking each entry, so a symlinked directory inside the tree is followed. - Language servers.
find_language_serverprefers<workspace_root>/node_modules/.bin/...before anything onPATH. Opening an untrusted repository therefore executes a binary that repository supplies. There is no signature check. - MCP tools. See the MCP section — entirely unchecked.
save_workspace_pathaccepts any canonicalizable directory, so the boundary itself is caller-defined. This is by design.- Config files under
~/.agent-ide(workspace.json,config.json,mcp.json,sessions.json) are outside the boundary by design. sessions.jsonholds conversation text in plaintext. Each saved session keeps up to 6 turns, each carrying the user's prompt truncated to 400 characters plus an outcome string that names changed files. It is grouped by workspace, capped at 50 sessions, and has no age bound and no file-permission hardening beyond whatever the home directory provides — so a prompt that contained a secret stays on disk until the cap evicts it or the user deletes the session from the history panel. Written via temp file + rename; there is no cross-process lock, so two app instances sharing the same home directory overwrite each other's session list (last writer wins), and an unreadable file is moved aside rather than merged.
Path traversal protection:
- Absolute paths are checked against the workspace root after canonicalization.
- Relative paths are joined against the workspace root first, then canonicalized and checked.
- Windows verbatim path prefixes (
\\?\) are normalized centrally to prevent canonicalization bypasses.
Symlink handling: canonicalization defeats ../ traversal and normalizes Windows verbatim prefixes before comparison, both covered by tests. But recursive traversal helpers re-check nothing per entry, and resolve_for_write does not canonicalize the final component of a path that does not exist yet — see Known Limitations.
The Tauri WebView enforces a CSP:
default-src 'self' ipc: http://ipc.localhost;
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' asset: https://asset.localhost data:;
connect-src 'self' ipc: http://ipc.localhost http://localhost:* https://*
- Script loading is restricted to
self— no inline scripts or external script sources. connect-srcallows HTTPS connections (required for LLM API streaming) andlocalhost(required for Tauri IPC and Vite dev server).- The CSP was restored after an earlier period where it was set to
null; it is now enforced.
LLM API keys and Git HTTPS credentials are stored via the OS credential store using the keyring crate:
- Windows: Credential Manager
- macOS: Keychain
- Linux: Secret Service (libsecret)
Implementation details:
-
Service name:
agent-ide -
LLM credential references:
llm-profile:<profile_id> -
Git credential references:
git-remote:<remote_url> -
~/.agent-ide/config.jsonstorescredentialRefstrings. Theapi_keyfield onLlmProfileis#[serde(default, skip_serializing)], so a config file written by this app never contains a plaintext key. -
Frontend responses carry
api_key_masked(first4****last4) andapi_key_usable.masked_api_key()probes the credential store rather than trusting the presence of acredentialRef, so an unreadable reference does not falsely claim a key is stored: it falls through tonot configured, or tofirst4****last4 (plaintext in config.json)when a plaintext key is what is actually there. The Settings panel decides "saved" fromapi_key_usable, not by comparing the mask against a string — a mask that reads as present while every run would fail is exactly the trap that made this area hard to diagnose before. -
The only plaintext-over-IPC path is
reveal_llm_api_key, used by the Settings eye toggle. It deliberately does not apply the plaintext opt-in: the gate governs whether we send a key to a provider, while the eye toggle shows the user their own file. Refusing there would print "cannot read stored key" next to a mask proving it was read. -
No key is transmitted anywhere other than the configured LLM provider endpoint.
Known limitations:
-
A plaintext key can persist on disk, but it is no longer used by default.
skip_serializingprevents writing one, but not reading one. A legacy or hand-editedconfig.jsoncontainingapi_keyis read on load, andmigrate_profile_credentialsthen tries to move it into the keyring; if that store operation fails the file is deliberately left unrewritten so the key is not lost. What changed is what happens next: the keyring is now read first, and a plaintext key is ignored unlessAGENT_IDE_ALLOW_PLAINTEXT_KEYis set to something other than0/false. Without the opt-in, the run fails with a message naming the profile, the keyring error if there was one, where to re-enter the key, and the variable. The Settings panel shows such a key assk-1****7890 (plaintext in config.json)rather than masking it identically to a keyring-backed one, andhas_readable_api_keyreports it as not configured — a profile that would fail every run must not look configured. There is still no file-permission hardening on~/.agent-ide. -
MCP tool arguments are logged with secret-looking keys redacted. Values under keys containing
token,secret,password,passwd,apikey,api_key,authorization, orcredentialare replaced with[redacted]before the argument JSON reaches the action log; arguments that are not valid JSON are not logged verbatim at all. Redaction keys on the field name, so a secret passed under an innocuous key is still logged. -
reveal_llm_api_keyhas no confirmation prompt, rate limit, or audit entry. -
Git HTTPS credentials are passed as plaintext over IPC by design, and persisted (when the user opts in) as
"{user}\n{pass}"in the OS store.GIT_USERNAME/GIT_PASSWORDare accepted as an environment fallback. -
macOS Keychain and Linux Secret Service backends are enabled but have not been runtime-validated. Windows Credential Manager has been verified end to end, including across an app restart.
Two of the advertised tools are not workspace access at all but the output protocol: emit_agent_changes and emit_sdd_draft go out with every native-tools request, carry no authority and are never executed — their arguments are synthesized back into the text the diff parser reads, so whatever they contain still has to pass through the review area to reach disk.
The Agent has six built-in read-only tools — workspace_read_file, workspace_search_text, workspace_glob, workspace_grep, workspace_list_files, workspace_read_image — so it can decide what to read instead of relying only on the pre-assembled context bundle. Unlike MCP tools, these are constrained:
- Path-taking tools (
workspace_read_file,workspace_read_image) resolve throughresolve_existing, so they cannot reach outside the workspace. The three search tools take no path: they are confined by walking from the workspace root, and symlinks are skipped entirely — a committednotes.txt -> ~/.ssh/id_rsais a regular file toread_to_stringand its reported path still looks in-workspace, so the only safe answer is not to follow it. - Credential files are refused outright, matching the context egress rule. Without that they would be a bypass: the model could simply call the read tool to get the
.envcontents the prompt builder withholds. - Traversal skips
.git,node_modules,target,dist,build,.agent-ide,artifacts,coverage.artifactsmatters beyond noise: it holds frozen whole-repo E2E copies, so without the skip every file appears twice and the Agent may quote or edit the stale copy. - Output is capped (64 KB per file read, 60 search hits, 200 directory entries). Searching also skips any single file over 2 MB and stops walking at 20 000 files — and when that walk cap is hit, the result says so, because "no matches" from a truncated walk is indistinguishable from "no matches".
- They cannot write, delete, move, or execute anything.
- Every call is written to the action log as a
workspace_tool_callentry, so what the Agent read is auditable. workspace_globandworkspace_grepwiden what can be found, not what can be reached. They walk through the same traversal skips and the same credential refusal asworkspace_search_text, so a.envis neither listed by name nor matched by content — one filter shared by all three, because three copies of the rule means one of them eventually forgets.workspace_globtranslates the pattern to an anchored regex (*and?stay inside a path segment,**crosses only when it is a whole segment, a pattern without/matches in any directory) and returns paths only. Patterns are normalized first — backslashes become/, a leading/or./is dropped — and brace expansion is refused rather than treated as a literal, because a silent zero-result teaches the model the files do not exist. On Windows the match is case-insensitive, soworkspace_globandworkspace_read_filecannot disagree about whetherREADME.MDexists.workspace_grepcompiles the pattern with theregexcrate — linear-time by construction, so a pathological pattern cannot hang a run the way a backtracking engine would — and reportspath:line:with each line capped at 200 characters and the result set capped like every other search. An invalid pattern is an error the model must fix, not an empty result, because "no matches" for a broken regex reads as "this code does not exist".workspace_read_imageadds four limits of its own: the type must be png / jpg / gif / webp (an unknown extension is refused locally rather than guessed), the size is checked withmetadatabefore the file is read (so a 2 GB.pngis refused instead of loaded), one image is capped at 4 MiB, and a whole run is capped at 16 MiB of image bytes. The run cap is the one the per-image cap cannot cover: a round executes every tool call the model emitted, so without it the total is unbounded. It is checked before the read (pinned by a test: with the budget nearly spent, a non-image file fails with the budget message, not the media-type one) and charged only after a successful parse, so a refused or non-image read does not spend it; the refusal names the used, requested and limit bytes so the model can choose a smaller image instead of retrying the same one. A run that inherits its tool surface from a previous run —continue_agent_pipeline,repair_workspace, both of which clone the permissions object — callsreset_image_budget(), so the counter follows the run and not the clone. A single request is bounded separately at 12 MiB of image base64 (fit_images_in_request), because a round can contain several read calls and the run budget alone would let ~22 MB of base64 into one request, over the ~20 MB most providers accept. Images past that are deferred to the next round, not discarded — they were already charged against the run budget when they were read, so telling the model to read them again would walk it into the cap with advice it cannot follow. The message names what happened ("Attached the first N of M … The last K … will be attached in the next step — do not read them again"), and the only case an image is genuinely lost is the tool loop ending first, which is reported to the user in the run's singlerun_degradedentry. The denial check runs twice — once on the path the model asked for and once on the resolved path — because a committed symlinkdocs/mockup.png -> ../.envpasses a name check andresolve_existingfollows it.read_filenow does the same. The image rides on ausermessage appended after the tool results (OpenAI Chat Completions accepts image blocks there, not on atoolmessage) and is sent once: the executor clears images from the transcript after each request, because a 4 MiB image is ~5.3 MB of base64 that would otherwise be re-sent every round of a 12-round tool loop. If the configured model is not a known vision model, or the endpoint is the local/mock text-only path, the images are removed and the text says so — and the removal is also reported to the user in that entry, naming how many images and why. The note in the message text is only visible to the model; on its own it left the user with an answer that looked normal and no way to tell whether the model ever saw the picture.
Everything that made a run do less than the user expected is reported as one run_degraded warning from finish_agent_run — tool calling rejected, images dropped, tool-loop history trimmed, the output limit clamped, reasoning effort refused. One entry with a scannable summary and the full reasons in its details, because seven separate warn kinds meant the seventh would be ignored and so would the other six. It fires on every exit path of a prompt, step or pipeline run including failure and Stop: a degradation is a fact about a request that already went out, and the failed run is the one that most needs the clue. agent_cli has no action log, so it prints one block to stderr — stdout carries the JSON/NDJSON a caller parses. It reports four of the five causes: the tool-rejection clause is desktop-only, because the CLI's tool surface is not built from a run's permissions.
One degradation is reported before the run instead of at the end: project_memory_truncated. AGENTS.md over 8 000 bytes loses its tail at injection, and the tail is where the most recently written rules are — so if the Agent then ignores them, that warning is the explanation, and waiting until the run finishes puts it a whole turn too late. The fact is carried out of context assembly on AgentContext.project_memory_truncated (the trimmed size of the file) rather than recovered by searching the injected text for a marker, because a marker inside the prompt is visible only to the model. Coverage is not the same as the other degradations: it fires on the prompt and step paths, which are the ones that assemble a context. A continued pipeline re-uses the context assembled for the paused run and does not repeat the warning, and repair_workspace never builds an AgentContext at all, so no project memory reaches it. The two context-estimate surfaces do not warn either — they only report sizes.
A model_override warning is reported the same way, before the run: replacing only the model name leaves the endpoint, key, token and spend caps, context budget and per-token prices with the selected profile, so the cost recorded for that run may be priced at another model's rate. It fires on all four run paths (prompt, step, continue, repair).
A tool loop re-sends every earlier round, so a long one can outgrow the context window mid-run — a handful of 64 KB file reads is enough. Before each request the executor measures the prompt against a budget of Max context − reserved output − 1 024 (LlmClient::prompt_token_budget) and, when it does not fit, drops the oldest exchanges from what the loop itself produced: the assembled prompt is never touched, an assistant tool call and its results are dropped together as a group, the most recent group is always kept whole, and the transcript gains a system line stating how many exchanges are missing. With the window unknown nothing is dropped — the provider's refusal is a better answer than a guess. Each trim is named in that same run_degraded entry, because the alternative is a run that silently re-reads files it already read.
They are advertised when the profile's toolCallMode is native_tools, which is the default for cloud profiles. If the endpoint rejects a tools parameter the client drops it, retries once, and the run's run_degraded entry says so — a run without tools is visible rather than silent. The tool loop is bounded at 12 rounds per stage, with the per-run token cap as the real cost limit.
ask_user_question is the one tool that reaches the user instead of the machine. The Agent gives a question and 2–4 short options; the run pauses until the user picks one, types their own answer, or declines.
- It authorizes nothing. The answer is a decision, not a permission — it can never widen what the Agent may do. It rides the same registry, id space and 120 s timeout as the approval gate (
ApprovalGate::ask_question), so Stop refuses a pending question exactly like a pending approval, and the dialog is always closed when the backend stops waiting. - No answer is never turned into an answer. A timeout or an unattended run returns a success result that says nobody answered and instructs the model to state which option it assumed; dismissing the question is an error result. An invented answer would be carried through the rest of the run as the user's stated preference, which is worse than no answer at all.
- A decision sent to the wrong kind of wait is never a yes. The registry is keyed by request id alone, so a stale frontend could send an approval to a question or an answer to an approval. Both map to a refusal, pinned by a test.
- The user always gets a free-text answer, and the model is forbidden from adding an "Other" option itself (rejected with an error). The options are what the model thought of, not the set of possible answers.
- Malformed arguments are refused before anyone is disturbed: fewer than 2 or more than 4 options, options that repeat case-insensitively, or an empty question. The error tells the model what to fix; opening a dialog the user cannot make sense of spends the one thing this tool costs — their attention.
- It is only advertised when a question channel is attached, so the CLI and other headless entry points never see it.
web_fetch is attached by default and asks for no approval. Looking something up is how an Agent finishes a task, and reading a public page has no effect on this machine that could need undoing. The safety here is a hard refusal where the harm is real, plus an audit trail afterwards — not a prompt before every request, which mostly trains the user to click through the prompts that do matter.
Refused outright, before any request goes out (services/web_fetch.rs):
- Anything but
http/https;httpis upgraded tohttps, because plaintext hands the whole URL, query string included, to every hop. - Credentials in the URL — refused, not stripped:
https://user:pw@evil.example/is a disguise for a different host. - Hosts that are not public: loopback,
*.localhost,*.local, single-label names (a company DNS suffix turnswikiinto an internal machine), RFC1918, link-local —169.254.169.254is one GET from cloud instance credentials — CGNAT, and the IPv6 spellings that decode to a private v4 (::ffff:10.0.0.1,64:ff9b::192.168.0.1). - Content types that are not text, JSON or XML: megabytes of binary decoded as text fill the context with nothing.
Bounded: 2 000-char URL, 10 MiB on the wire counted while streaming (a content-length header can lie), 100 000 characters of text with a visible truncation marker, 30 s timeout, at most 10 redirect hops.
A redirect to a different host is not followed. The target is handed back to the model, which must call the tool again with that address — so it passes the same refusals and earns its own audit entry. Following it silently would carry one host's approval to another.
What comes back is marked untrusted: the text is wrapped with an explicit instruction that it is third-party data to read, never instructions to obey. A fetched page is text a stranger can edit, and may well contain "ignore your previous instructions". The wrapper does not defeat a determined injection, but the default reading of a web page must be material, not orders.
Every fetch is recorded as an external action (web_fetch, web_fetch_redirected, web_fetch_failed) with the final URL and how much was read, so "what did it look at" is answerable afterwards. Known limit: no DNS pre-resolution, so a public domain that resolves to a private address is not caught by the host rules above.
delegate_task hands a self-contained question to a read-only subagent and returns its final text. It is only advertised when a subagent channel is attached, which the four desktop run entry points (send_agent_prompt, run_agent_step, continue_agent_pipeline, repair_workspace) do; a headless entry attaches none, so there the tool is absent rather than failing when called. A profile whose tool_call_mode is not native_tools also gets no channel, because a subagent with no tools can read nothing and would answer anyway.
What the child cannot do is structural, not prompted:
- Its permissions are built from
read_only(), so there is no write, no command run, no desktop or browser authority — and a capability added to the parent later is not inherited, because the child is constructed fresh rather than by subtracting from the parent. - The tool list it is offered is computed from those same permissions. The tools in a request body come from the client while the calls are served by the invoker, so the child gets its own client (
SubagentChannel::child_client) carrying exactlytool_definitions(child_permissions): the parent's write tools, its MCP tools anddelegate_taskitself are replaced, not appended to. Advertising a tool the child's invoker refuses would spend its limited rounds on calls that cannot work. - It gets no subagent channel, so recursion depth is exactly 1. The prompt says so too, but the prompt is not what enforces it.
- It shares the parent's cancel switch: Stop stops the child as well, not just the outer layer.
- Its tool rounds are capped at 8 and the cap is enforced. When it stops a child early, the caller is told — and the two facts behind that sentence (
tool_rounds,hit_round_cap) come from the loop's own bookkeeping rather than from counting messages afterwards, because the last round executes no tools and history trimming deletes the oldest ones. A half-finished answer that reads as finished is worse than none. - Its reply is bounded (20 000 chars) and its stream never reaches the chat — the caller gets a conclusion, the user does not get two voices interleaved.
The child's client is a clone of the parent's, so the run's usage meter and degradation records are shared: a delegation spends the same run's budget and its degradations appear in the same report. The two paths that reuse a previous run's permissions (pipeline continue, repair) replace the channel with this run's client for that reason — an inherited channel would bill the child to a run that has already ended.
Every delegation is recorded as an external action (delegate_task) with the description and what it cost in rounds: a delegation is a full model loop, so the money is spent and cannot be taken back, which is exactly what that log is for.
workspace_computer_windows lists the visible top-level desktop windows — title, app, size, and which one is in the foreground. This is the first slice of computer use and it is read-only: nothing on the desktop is clicked, typed into or captured.
- Two independent gates, plus the platform.
allows_computer()requires theallowComputerUsegrant, a non-empty app allowlist, andcfg!(windows). Only Windows has an implementation; elsewhere the tool is not advertised at all, because a tool that always fails gets called repeatedly and its failure reads as "there are no windows". - The allowlist filters the results, not just the tool's existence. This is the mistake
workspace_browser_tabsmade: there, allowlisting one origin still disclosed every open tab. Here, windows whose app is not on the list are removed from the output, and only the count of hidden ones is reported. Matching is on the executable name only —normalize_app_namestrips path, case and.exe, soCode.exe,codeandC:\…\Code.exeall mean the same thing. Pasting a full path does not scope the entry to that install: any process namedcode.exe, wherever it lives, matches. - Window titles are the thing being protected. They carry document names, page titles and the other party's name in a chat window. That is why the authority is per-app and the disclosure is recorded, even though the tool changes nothing.
- A window whose process cannot be read is never disclosed, not even under
*.window_appreportsunknown, andapp_allowedrefuses that value outright: the usual reason the process handle cannot be opened is that it belongs to an elevated process, and those windows are disproportionately the sensitive ones. A window nobody can attribute to an approved app is not covered by any approval. (Cost: a realunknown.exeis also invisible.) - Recorded like an irreversible action. A successful listing lands in the run's external action log as
computer_windows, naming how many windows were disclosed, how many were hidden, and which apps; a call refused after Stop lands ascomputer_windows_cancelled; a transport-level failure ascomputer_windows_failed. The disclosure itself cannot be taken back, which is the same reason navigation is recorded. - An unauthorized call never reaches the tool at all.
handles()returns false, and bothselect_external_callsandCompositeToolInvoker::invokefilter on it, so the model's call is dropped before dispatch and leaves no external-action record —computer_windows_refusedexists only as a defence-in-depth check for a direct caller. The same is true of the browser tools' in-tool gate. - Stop applies. The tool is in the side-effect gate list, so a call that has not started is refused after Stop.
Window capture (workspace_computer_capture) is a second, separate grant: its own switch plus its own non-empty app list. It deliberately does not ride on Desktop Observation — a title says "Signal is open", a capture shows the messages — so allowing the Agent to see that a window exists never implies allowing it to see what is inside.
What it enforces:
- One named window, never the screen.
PrintWindow(PW_RENDERFULLCONTENT)against a specificHWNDfrom the same enumeration, so windows stacked on top are not copied in. A full-screen grab has no scope any per-app allowlist could describe, which is why there is no such tool. - Ambiguity captures nothing. The model names the window with
appand/ortitle_contains; zero matches, or more than one, is refused with the candidate list so it can narrow down. Guessing would be an unrecoverable disclosure of the wrong window. - The user approves the capture, per capture, and is shown which window matched. The tool resolves the target first (
resolve_capture_target) and then asks (see Per-Action Approval), because the model supplies a filter, not a window —app: "chrome"can match any Chrome window, so the prompt has to name the result of the selection rather than the request. Refusals, timeouts and Stop all leave the pixels uncopied. - The approved window is identified by its handle and its process, not by its title.
resolve_capture_targetkeeps theHWND's value (anisize, which crosses anawaitfine) plus the window's pid;capture_approved_windowreads the handle back andApprovedWindow::verifyrefuses unless the window is still alive, still belongs to the same app, and still belongs to the same process. Win32 recycles handles, so "still valid" is not "still the same" — and a handle recycled by another window of the same app (Chrome closing one window and opening another) is exactly the case an app-name check cannot see. Re-finding the window by title would be worse than nothing here: a window title is set by the thing being disclosed (a page'sdocument.titleis its window title), so the model can arrange for a second window to answer to the approved name. Stated limit: if the pid could not be read when the prompt was shown, the check degrades to the app name alone. - A title that changed is not treated as a different window, and the record says so. Titles tick with unread counts, playback position and dirty markers, and refusing on every tick would turn approved captures into repeated prompts — the approval fatigue this design is trying to avoid. The consequence is stated rather than hidden: if the window's content changed while the prompt was open, what was captured is the new content, so the record names the title at capture time and adds
(approved as "…")when the two differ. - Windows outside the allowlist are not even named in the refusal — it reports only how many allowed windows exist.
- Two size gates, in this order: the pixel count (4 000 000, so a 2560×1440 window fits) is checked before any bitmap is copied — and before the user is asked, so a window that would be refused anyway never produces a prompt — then re-checked after approval against the window's current size, and the encoded PNG is checked against the same 4 MiB per-image cap
workspace_read_imageuses. It also charges the same 16 MiB per-run and 12 MiB per-request image budgets, so a capture and a file read compete for one allowance. - Every attempt is an external action record —
computer_capture,computer_capture_refused,computer_capture_failed,computer_capture_cancelled— naming the app, the title and the pixel size on success. A screenshot cannot be taken back, so it is logged like a navigation, not like a read. A capture the user denied records the window title: they read it in the prompt, so the record is not a new disclosure, and the error returned to the model still does not name it.
One consequence worth stating plainly: capture is not fully independent of observation in what it reveals. With captureApps set to *, a run holding only the capture grant can probe title_contains and learn window titles plus a count of allowed windows from the refusal messages — the same class of information Desktop Observation gates. It is arguably subsumed (such a run could screenshot those windows anyway), but "capture without observation" should not be read as "cannot learn what is open".
A second, about verification rather than behaviour: the capture approval path has no automated test. allows_capture() requires cfg!(windows), and on Windows resolve_capture_target enumerates the real desktop, so the tool cannot be driven deterministically in CI. What is covered is the decision it delegates to — ApprovedWindow::verify (closed window, handle recycled by another app, handle recycled by another window of the same app, changed title, missing pid) and CaptureTarget::describe — plus the whole approval mechanism through workspace_browser_open, which is tested end to end. The wiring between them is checked by review only. That is weaker than the browser path and is not presented as equivalent.
Mouse input is implemented and is documented under "Window Click and Scroll" below; the design it follows is recorded in ROADMAP 86 and 110. Keyboard input is still not implemented, deliberately: typing is irreversible, it goes wherever focus is — which a screenshot cannot pin the way a frame pins a coordinate — and it would need its own authority pair. A session switch is the right floor for that and the wrong ceiling, so no switch ships on its own.
Two further tools — workspace_browser_open and workspace_browser_tabs — drive the user's own Chrome over the DevTools Protocol. They are the first Agent capability whose effects the product cannot undo, so the guarantee offered is authority plus a record, not reversibility.
What the backend enforces:
- Two independent gates.
WorkspaceToolPermissions::allows_browser()requires both theallowBrowserUsegrant and a non-empty origin allowlist. An empty list is not read as "unconfigured, so allow everything" — that reading is exactly what looks like user approval after an incident. Neither gate alone advertises the tools, andhandles()returns false, so a model that names the tool anyway gets "unknown tool" rather than a tool that always fails. - Plus a third gate that is not static: the run must ask a human, per navigation. After the two grants pass,
workspace_browser_opensuspends onApprovalGate::askand does nothing until someone answers. The order matters and is tested: a URL outside the allowlist is refused without prompting, so every prompt the user sees is one that would otherwise proceed. Five outcomes, four of them refusals — approved, denied by a person, refused by Stop (Cancelled), timed out (120 s default), and "no approval channel attached to this run" (Unattended) — and each refusal writes its own wording into the record, so "the user said no", "Stop answered for them" and "nobody was there to ask" stay distinguishable. See Per-Action Approval below. - The gate is re-checked inside the tool, but that particular branch is defence in depth for a direct caller only:
handles()already drops a call to an unadvertised tool before dispatch (select_external_callsandCompositeToolInvoker::invokeboth filter on it), so "browser use was off entirely" produces no external-action record. The reachable refusals are the ones decided inside an advertised tool — a rejected scheme or an origin outside the allowlist — and those are recorded. - Origin-scoped, not URL-scoped.
services::browser::origin_ofreduces a URL toscheme://host[:port]with the authority lowercased, andorigin_allowedcompares case-insensitively against the list — exact match, or*for any. The list is captured when the run starts (browserOriginsin the request); see the limitations below for what that means for a continued pipeline. - Scheme allowlist before any network call.
normalize_target_urlaccepts onlyhttpandhttps, and rejects control characters, a missing host, anduser:pass@credentials.javascript:would run script in the current page's origin,file:reads local files outside the workspace boundary, andchrome:reaches the browser's own settings — all three are refused before a request is made. - Loopback only. The CDP endpoint is
http://127.0.0.1:{port}(AGENT_IDE_CDP_PORT, default 9222). CDP has no authentication whatsoever: anything that can reach the port controls the browser, so the port is never taken from remote input. - Stop refuses side-effecting calls that have not started yet, and kills a command that has. The run's cancel flag is the same
Arc<AtomicBool>the tool surface holds (WorkspaceToolPermissions::fresh_cancel()mints it,try_begin_runpublishes it), soWorkspaceToolInvoker::invoke()refusesworkspace_run_command, the write / delete / move tools and all three browser tools once Stop has been pressed.workspace_run_commandalso carries the flag intorun_project_command_cancellable, which polls it while the child runs and kills the process tree (taskkill /T /Fon Windows) instead of waiting the command out; the tool then reports "Stopped: … was killed", not an exit code, so the repair loop cannot mistake it for a passing check. Read-only tools still run — they change nothing, and refusing them would only add noise to a transcript that is being abandoned. A browser attempt refused this way is recorded asbrowser_open_cancelled/browser_tabs_cancelled/browser_read_page_cancelledand counted as not performed. What this does not do: an in-flight CDP request is still bounded only by the timeout below, a Unix command that forks past its shell can outlive the kill, and an MCP call that is already in flight cannot be interrupted — MCP has no cancellation in the protocol as we use it, so the gate only prevents new calls. - Every CDP request has a 10-second timeout. For
workspace_browser_tabsthese calls run inside a synchronous tool function (block_on_browseryields the worker thread withblock_in_place);workspace_browser_openandworkspace_browser_read_pageawait directly, since they already suspend for approval. - CDP requests never go through a proxy.
cdp_client()setsno_proxy(), because reqwest otherwise honoursHTTP_PROXY/ALL_PROXYand would send requests aimed at 127.0.0.1 to whatever proxy the machine has configured. Two consequences, one of them a disclosure: the browser tools would fail with unrecognisable errors instead of the actionable "start Chrome with--remote-debugging-port", andopen_urlputs the target URL in the request line — through a proxy that is an unauthorized third party learning what the Agent is opening. Either way, without a timeout a port that accepts the connection and never answers parks the run: Stop marks the UI idle, the backend task never finishes, and the external action log is never published — an irreversible capability losing the one record that compensates for it. - Every attempt is recorded, including the refused ones. Successes, refusals and transport failures land in the run's action log as
browser_open/browser_open_refused/browser_open_failedandbrowser_tabs/browser_tabs_refused/browser_tabs_failed, with the text "These cannot be undone." A refusal that only appears in the tool's return value disappears with the conversation, and "the model tried to open a site it was not allowed to" — or tried at all while the switch was off — is precisely what the user wants to find afterwards. - The record lives on the orchestrator, next to the diffs — and on disk.
record_external_actionsappends toAgentOrchestrator.external_actions(bounded at 200, newest kept) andget_agent_external_actionsreads it back, so a frontend reload still shows what the run did outside the workspace.publish_external_actionsalso appends the newly recorded entries to~/.agent-ide/external-actions.json(agent::external_log), andAgentGlobalState::new()reads the current workspace's entries back at startup, so the record now survives closing the app. The action-log entry is a third copy, not the only one — an emit-only record was lost whenever no window was listening, sincetake_external_actionsdrains the tool-side list. - Each record names the run that performed it, taken from
WorkspaceToolPermissions.run_id, which the command layer stamps when the run lease is granted. It is deliberately not re-read at publish time: a run that was stopped can drain its records after the next prompt has started, and reading the orchestrator then files the old navigation under the new prompt. - A restored record says it is restored. Entries read back from disk carry
restored: true, and the Diff view tags them "previous session" instead of the "earlier run" chip.run_idalone cannot carry that distinction — after a restart every run id is unfamiliar — and "this just happened" versus "this happened last week" is the difference the user actually needs. - The review area shows them without an Undo button. The Diff view lists every record newest-first in a scroll box, headed "N external action(s) — cannot be undone", refusals counted separately, and records from an earlier run or an earlier session tagged as such. The absence of a button is the point; a control that pretended to reverse a navigation would be the worst possible answer.
What it does not do: there is no page interaction — no clicking, typing, form submission, or model-supplied script. Reading a page's text is a separate capability with its own grant; see Page Reading.
External actions are deliberately a separate list from writes. A file write carries its previous content and an undo checkpoint; a navigation carries neither. One list would let the word "undo" mean two things in the same UI, one of them false.
Limitations worth stating plainly, because the record is the whole compensating control:
- The on-disk log is plain, unencrypted JSON and is written non-atomically. It lives in the app's config directory (
AGENT_IDE_CONFIG_DIR, else~/.agent-ide) next tomcp.jsonandconfig.json, and is rewritten whole withstd::fs::writelike every other config file here — a crash mid-write can truncate it. When the log cannot be read, it is moved aside (external-actions.unreadable-<timestamp>.json) and a new one is started, and the run reports that as a warning in the action log. Both halves matter: refusing to write would mean one bad crash silently ends recording forever, and overwriting in place would mean "start clean" deletes the evidence. Disk is chosen over a localStorage mirror because the diffs have an authoritative backend copy and these do not: the only copy of an irreversible-action log should not be erasable by page script or by "clear site data". The config directory is also on the Agent write deny-list (.agent-ide) and outside the workspace boundary, so the Agent's built-in write tools cannot reach it — an MCP server the user configured still can (see MCP Tool Exposure), and so can the user. - A failed write is reported, not swallowed.
append_for_current_workspacereturns an outcome andpublish_external_actionsturns anything other than success into awarnaction-log entry naming the reason. "The Agent did nothing outside the workspace" and "the record could not be written" look identical otherwise, and only one of them has a next step. - The log records the workspace and is re-scoped when the workspace changes.
save_workspace_pathreloads the in-memory list for the new workspace. Without that, the previous project's actions stayed on screen and in the Changes badge while new records were filed under the new project — a record that names the wrong project is worse than a missing one. - The Changes badge counts this session only. Restored records are in the list, with their date, but not in the badge: a count that includes months-old history can never reach zero and no action can dismiss it, which trains people to ignore the badge that exists to catch their attention.
- The durable log is itself a disclosure surface. It contains the same strings the review area shows: full URLs including query strings (
browser_open,browser_read_page) and window titles (computer_capture). SECURITY.md already names magic links and OAuth callbacks with tokens in the query string as a real exposure forworkspace_browser_tabs; those persist in plaintext in the home directory, with no age bound and no redaction, and there is no file-permission hardening on~/.agent-ide. - The only way to prune it is deliberately narrow.
forget_earlier_external_actionsremoves this workspace's records from earlier sessions and leaves a tombstone (external_log_cleared) saying how many were cleared. Two choices worth stating: it cannot touch the current session's records, because a control that erases what just happened turns the record into something deniable and it stops being a compensating control; and it leaves the tombstone, because a log that silently got shorter and a log somebody trimmed look identical afterwards. The tombstone is bookkeeping, not an action, so it is excluded from the "N external action(s)" count. An unreadable log is not cleared — that would turn a file a human could still inspect into a certain deletion. - The disk log is bounded at 500 entries and shared across workspaces. Each entry records which workspace it belongs to and only that workspace's entries are restored, but the 500 is a global budget: a busy project can push another project's older records out. Restoring more than 200 entries also drops the oldest of them, because the in-memory list keeps its own bound and the current session's records are the ones the list exists to show.
- Two app instances against the same home directory can lose each other's records. The append is an unlocked read-modify-write and there is no single-instance guard, so the later writer wins. One process is the only supported shape today.
- Nothing else about a run is persisted. Undo checkpoints and the conversation stay in memory, so a restart still loses the ability to undo an applied diff; only the irreversible-action record is now durable.
- A run that only navigated does badge the Changes tab, in a different colour from a pending change: the count is of actions that already happened and cannot be undone, not of things waiting for a decision. The action-log entry is the other notification path.
- A redirect leaves the authorized origin. The allowlist is checked against the URL the model supplied; Chrome then follows redirects with no further check, so an open redirect or a shortener on an allowed host can land the browser elsewhere. The success record names the requested URL, not the final one.
workspace_browser_tabsresults are not origin-scoped. The allowlist decides whether the tool exists, not what it returns: allowlisting only a local dev server still discloses the title and URL of every open page — internal sites, ticket URLs, magic links, OAuth callbacks with tokens in the query string. The record names the count and the distinct origins disclosed so the exposure can be reconstructed.- A continued pipeline uses the authority captured at its start.
continue_agent_pipelineclones the permissions stored on the orchestrator, so revoking Browser Use in Settings does not affect a paused run that is then continued. The same is true of command execution. Both it and the auto-repair loop mint a fresh side-effect switch and rebuild their tool surface, so Stop applies to them like any other run.
Browser use is deliberately not part of the permission preset ladder. All three presets (read-only, create-files, run-commands) set allowBrowserUse: false and an empty origin list; choosing run-commands to let the Agent run tests must not silently also approve outbound navigation.
workspace_browser_read_page returns the visible text of a page the user already has open. It is read-only in the sense that nothing on the page changes, but what it discloses is the page's content — including whatever is only there because the user is signed in — so it is treated like window capture, not like listing tabs.
What the backend enforces:
- Its own pair of gates, not
allowBrowserUse.allows_page_read()requires theallowPageReadgrant and a non-emptypageReadOriginslist. Reusing the navigation grant would silently upgrade "may open a page" into "may read every page you are logged into" — the same mistake that capture avoids by not reusingcomputerApps. Neither direction implies the other, andeach_flag_lands_on_its_own_permissionpins both. - Plus per-read human approval, in the same order as everywhere else: static authority first, then the prompt. The tool resolves which page matched before asking (
select_read_target), because the model supplies a filter, not a page; the dialog names the page's title and URL, and the approval is re-checked against Stop before the read happens (a_stop_between_the_approval_and_the_read_still_prevents_it). - The origin is re-verified against the page itself, after the read. The allowlist decision is made on the
/json/listsnapshot, but the debugger socket is bound to the target, not to a URL — a page that navigates during the up-to-120-second approval wait keeps the same socket. So the extraction expression returnslocation.hrefalongside the text, from the same evaluation, andverify_read_originrefuses if that origin is not the approved one. The text has reached this process by then; it does not reach the model, and the refusal does not name where the page went (that origin is precisely the unapproved one). This is the browser-side counterpart ofApprovedWindow::verify. - At least one filter is required. An empty filter would match every allowed page, and the ambiguity refusal lists candidates — so a bare call would be
workspace_browser_tabswithout its grant.select_capture_targetrefuses a filterless call for the same reason. - The allowlist filters the candidates, not just the tool's existence. Pages outside
pageReadOriginsnever participate in matching and are never named; a no-match refusal reports only how many were excluded. Within the allowlist the ambiguity refusal does list the matching pages so the model can narrow down — the same disclosure window capture has, bounded by the same two things: the grant, and the requirement to name a substring first. - Ambiguity is a refusal. If more than one allowed page matches, nothing is read and the candidates are listed. Picking "the first one" would make the disclosure depend on Chrome's ordering.
- The evaluated script is fixed.
page_text_expressiontakes a character limit and nothing else; there is no tool parameter that reachesRuntime.evaluate. A model-supplied expression would run in that site's origin with the user's session — able to read cookies or act as the user — which is not what "read this page" was approved for. - The debugger socket is validated before we connect to it.
webSocketDebuggerUrlcomes from Chrome's own response, andvalidate_page_ws_urlstill requires it to start withws://127.0.0.1:{port}/. It is a field that decides where we connect, so it is checked rather than trusted. - Truncation happens in the page, and says so. The extraction expression slices to 20 000 code points (
Array.from, notslice— cutting on a UTF-16 unit can leave half a surrogate pair, which is not valid JSON) and reports the untruncated length, so a long document costs one bounded WebSocket frame and the model is told how much it did not see — a silently cut-off page reads like a complete one. - Bounded, and never silent on failure. The whole exchange is wrapped in the same 10-second timeout (a WebSocket has no default one, and a page whose main thread is busy would otherwise park the run). The close handshake is deliberately not awaited: it is inside that timeout, so an unflushed close would turn a read that already succeeded into "the page did not answer". A CDP-level error, an exception thrown inside the page, a missing
location.href, and an unexpected answer shape are four distinct errors — none of them is reported as "the page was empty", which the model would take as a fact and reason from. - Every attempt is recorded, as
browser_read_page/browser_read_page_refused/browser_read_page_cancelled/browser_read_page_failed, with the number of characters disclosed and the URL the text actually came from. A page whose text turned out to be empty is still recorded: it was still an attempt to disclose. As with the browser tools, the "page reading was off entirely" branch is defence in depth for a direct caller —handles()already drops the call before dispatch, so that particular refusal cannot appear in a real run.
What it does not do: it does not navigate, click, type, or read a page that is not already open — and a page whose DevTools window is open cannot be read at all, because Chrome offers only one debugger per target. That case is reported as itself rather than as "not found". Stop does not abort a read that is already in flight; it is bounded by the 10-second timeout, so up to that much disclosure can complete after Stop.
Page reading is not in the preset ladder either: all three presets set allowPageRead: false with an empty list.
workspace_computer_click sends a mouse click — left, double, or right — into a window, and workspace_computer_scroll turns the wheel over a point in one. Together they are the most dangerous capability in the product: pointer input cannot be undone, and it can dismiss any confirmation dialog — including this application's own approval dialog. Their shape reflects that.
Both tools are one code path (computer_pointer_tool over a Gesture), deliberately: authority, frame binding, identity re-verification, approval and Stop matter equally for a right-click and for a scroll, and a second copy of that path is how one of those checks ends up missing on one branch.
What the backend enforces:
-
One pair of gates for both,
allowComputerInputplus a non-emptyinputAppslist, and Windows only. It reuses neitherallowComputerUse(seeing that a window exists) norallowComputerCapture(reading what is in it): those are disclosures, these act. Neither direction implies the other, andeach_flag_lands_on_its_own_permissionpins that. A scroll is not a lesser authority than a click — it can move a page under a button the user is about to press — so it gets no gate of its own. -
Coordinates only exist against a frame the model has already seen.
workspace_computer_capturemints a frame id (window handle + pid + class + size), and both tools takeframe,x,y— there is no window selector on either at all. This is the core of the design: choosing the window by filter is how you click the wrong window's dialog, and the frame binds "what you looked at" to "what you acted on". A call with no frame is refused with instructions to capture first. In practice this means real pointer input needs both grants, but that is enforced by the frame rather than by making one switch depend on the other. -
The frame's app is checked against
inputAppsat call time. The frame was produced under the capture grant, and the two lists can differ — without this check, "may screenshot Signal" would quietly become "may click Signal". -
An unrecognised
actionis refused, never downgraded to a left click."click","double_click"and"right_click"are the whole set; anything else fails with the list. Silently substituting a left click for a gesture the model asked for would perform an action nobody approved, on a path that cannot be undone. -
A scroll is bounded to 10 notches either way and 0 is refused. Approval is per call, so an unbounded
notcheswould turn one approval into unlimited authority; and reporting success for a scroll that moved nothing would tell the model the page is at its end. The bound is checked on the same path as every other check, not in the argument parsing of one entry point —Gesture::Scrollis constructible elsewhere, and the invariant belongs next to the injection rather than next to one caller. A refused scroll is still recorded, because "it tried to scroll 500 notches" is exactly what the bound exists to surface. -
Plus per-call human approval, in the usual order: static authority, then the frame, then the prompt — which names the window, the gesture in words ("a right click (opens the context menu)", "a scroll down by 3 notch(es)") and the exact coordinates. The same sentence goes into the record, from the same function, so the two cannot drift. Stop is re-checked after the approval.
-
Identity is re-verified with the same four checks as capture (
ApprovedWindow::verify— alive, same app, same pid, same window class), because a handle can be recycled to another window between the capture and the click. App and pid catch recycling across processes; the window class catches the most common in-process case (a main window closes and a tooltip or popup inherits the handle —Chrome_WidgetWin_1versustooltips_class32). What remains is two sibling windows of the same class in the same process, which are indistinguishable to Win32 — it offers no stable per-window identity — and there only the frame's size check stands between the model and a window it never captured. That residual is real and documented rather than papered over. -
Out-of-bounds coordinates are refused, not clamped, and off-desktop screen points too — clamping turns a miscalculated click into a click in a corner, and corners have things in them.
-
The whole gesture goes in a single
SendInputcall — move plus down/up, both down/up pairs of a double click, or move plus wheel — so a user moving the real mouse mid-sequence cannot turn a click into a drag or split a double click into two singles. If only part of the batch is accepted, the buttons still held by the part that was accepted are released so the desktop is not left mid-drag; a batch that stopped at the leading move pressed nothing and gets no compensating mouse-up, because a fabricated button-up can complete a press the user was making. The call still reports failure. -
The cursor is put back, on the success path and on the partial-send path alike. Its position is read before the input and restored after, so the pointer does not stay parked where the Agent aimed, silently changing hover state and the user's next real click.
-
Every attempt is recorded as
computer_clickorcomputer_scrollwith the usual_refused/_cancelled/_failedsuffixes, and every one of them names the gesture — including the refusals that happen before a frame is even resolved, and the ones where the arguments never parsed. That matters because one record kind covers three click gestures: without the gesture in the text, a refused right click and a refused left click are the same line. Records add coordinates, window title and frame size as soon as those are known. Refusals after Stop are recorded againstdesktop; the target is not taken from any model-supplied argument, which previously let aurlparameter file a refused desktop action under a web address. -
The capture pixels come from a real, tested path.
a_real_window_is_captured_at_the_size_it_reportscreates a window, finds it by app and title, and checks the decoded PNG against the size the capture reports — thePrintWindow+GetDIBitscode is otherwise invisible to tests, and a wrong stride or a dropped channel would look plausible in a thumbnail. -
Each gesture has a test that really sends it — when the session allows it.
a_click_reaches_the_window_it_was_aimed_at,a_right_click_arrives_as_a_right_click,a_double_click_arrives_as_one_double_clickanda_scroll_arrives_with_the_notches_it_asked_forcreate a real window and call the production path. Each compares all four of the window's counters — left clicks, right clicks, double clicks, wheel events — against one expected set, so a gesture that arrives as a different gesture fails rather than satisfying a partial check: a left click that regressed into a double click is caught becausedouble_clicksmust be 0. The click also checks the point is inside the client area; the scroll also checks the delta is-2 × 120, direction included. If the session refuses (acargo testrun launched from a background shell, or a headless CI session) each asserts the call was refused and that all four counters are 0, and prints which path it took. So the suite covers the injection conditionally, and the printed line is the only honest way to know whether a given run reachedSendInput— on a background-shell run, all four take the refusal branch. -
The evidence those tests rest on is verified unconditionally. Because the arrival branch does not run in every session, a mis-decoded wheel delta, a double click counted as a left click, or a test window that quietly lost
CS_DBLCLKSwould leave those tests green forever.the_test_window_counts_each_kind_of_mouse_input_separatelyposts the four messages straight to the window and checks each counter and the signed delta, and asserts the class style still carriesCS_DBLCLKS; it runs in every session. The event sequences are pinned separately by pure-function tests (each_gesture_sends_its_own_buttons,scrolling_is_measured_in_wheel_deltas,only_the_buttons_left_held_by_the_accepted_part_are_released).
What it does not do: no typing, no key presses, no drag, no middle button, and no reading of the result — after any of these the model must capture the window again to see what happened. There is no full-screen coordinate space: every action is relative to one approved window.
One guarantee that does not extend to the wheel: Windows delivers WM_MOUSEWHEEL to the focused control (or, with "scroll inactive windows when I hover over them" enabled, to the one under the pointer), not to the point in the message. So for a scroll, the coordinates move the pointer there and every check above still binds the window — but which pane actually scrolls is the system's decision, not ours. The approval prompt, the tool description and the reply to the model all say so, because a prompt that implies point precision would be asking the user to approve something the backend cannot promise.
Known limits: this is real system input, so it lands wherever that window is — if the user moves the mouse or types at the same moment, the two streams interleave. Stop does not abort input already in flight (it is a single Win32 call, so the window is microseconds, not seconds), but the foreground wait before it can take up to 500 ms. A double click relies on the target window's class having CS_DBLCLKS: without that style Windows never sends WM_LBUTTONDBLCLK and the window sees two single clicks, whatever the timing. Timing itself is not the risk here — all four events ship in one batch with no delay, so the gap is inside any double-click interval. The test window carries CS_DBLCLKS for that reason: it verifies the claim as stated rather than a version relaxed for testing. DPI: the shipped app is Per-Monitor-V2 aware (tao calls SetProcessDpiAwarenessContext), so GetWindowRect — the same call the capture path measures with — and the virtual-screen metrics share one coordinate space; under cargo test there is no event loop and the process is DPI-unaware, which is why the injection test's coordinate assertion is deliberately loose. Mixed-scaling multi-monitor is still unverified on real hardware, and the frame's size check is what would catch a mismatch rather than a silent mis-click. Pointer input is not in the preset ladder: all three presets set allowComputerInput: false with an empty list.
Model Context Protocol servers are the largest privilege surface in the product, and the one with the fewest backend guarantees. This section states plainly what is and is not enforced.
What the backend enforces:
- Tool-name gating only.
McpToolPolicydecides which discovered tools are advertised to the model and re-checks the name at call time:Denyexposes nothing,AutoApprovedOnlyexposes only tools the user listed in that server'sautoApprove,AllowAllexposes everything. An unrecognized policy string falls back to the restrictiveAutoApprovedOnly, andautoApprovedefaults to empty. - Tool arguments must be a JSON object.
- The server process is spawned with its
cwdresolved inside the workspace. - Qualified tool names are unique. Tool names are sanitized into
mcp__{server}__{tool}, which is lossy —.and spaces both become_, and the separator is itself__, so two distinct servers can sanitize to the same advertised name. Discovery now claims names first-come and drops later collisions, reporting them on that server's status. Without this, the call-time lookup took the first match and a tool call could silently land on a different server than the model named. - Tool results are capped at 64,000 characters before they re-enter the conversation, with the truncation stated in the result text. Non-text content parts (image/audio) are replaced by a placeholder rather than inlined as base64.
What the backend does not enforce:
- Nothing about what a tool actually does. Arguments are forwarded opaquely. There is no path validation, no workspace-boundary check, no read/write distinction, and the Agent-write deny list below does not apply. An MCP filesystem tool can write
.git/hooks/pre-commitor read~/.ssh/id_rsa, and those effects never appear in the diff-review UI. What is enforced: after Stop,McpToolInvoker::invokerefuses to issue further calls and logs the refusal; a call already in flight cannot be cancelled. - Every call is recorded where it survives the window closing. Each MCP call lands in the durable external-action log —
mcp_tool_call,mcp_tool_failed,mcp_tool_cancelled— beside browser navigations andweb_fetch, because it belongs to the same class: done, cannot be taken back. A failed call is recorded too: the error comes from the server, and is not evidence that nothing was written. Before this, an MCP call existed only in the in-memory action log, so "what did that server do yesterday" had no answer at all. - The record names the paths the arguments named, split into inside and outside the workspace. Paths are read from a fixed list of argument keys (
path,file_path,files,cwd, …) rather than sniffed out of every string: MCP arguments carry URLs, regexes and whole files, and guessing produces a log nobody reads. The classification is purely lexical —..is reduced, a sibling directory whose name shares a prefix is outside — and it never refuses the call. A key table cannot be an authority (the schema belongs to the server; rename a key and the check is gone), and refusing on a guess would break the filesystem servers a user deliberately pointed at another directory. So the boundary here is a statement, not a gate. - In-workspace files the arguments named are snapshotted before the call and compared after it. Anything that really changed is published to the review area as an
applieddiff carrying its pre-call content, with an undo checkpoint — the same mechanism the built-in write tools use, except the "before" copy is taken for the tool instead of by it, because an MCP result is free text and never says what it wrote. Creations and deletions are detected too. Each candidate path goes throughresolve_for_agent_writefirst, the same resolution a built-in write uses, so the deny list (.git/,.env, credential files) and symlinks leading out of the workspace are skipped rather than read into a review card and an undo snapshot — taking a copy means putting those bytes on screen and writing them back on undo. - The record says "changed while that tool ran", not "written by it". The comparison establishes that the bytes differ across the call, not who changed them: a user saving that file in the editor during a long call, or a watcher rewriting a generated file, looks identical. The provenance rationale says so, because a user acting on a wrong cause can undo the wrong change. If one file is touched by both a built-in write tool and an MCP call, it produces one card and one undo checkpoint whose pre-image is the earliest of the two, and the rationale names both.
- What that coverage does not include, stated in the record itself: paths outside the workspace, deny-listed paths, files over 1 MiB, files that are not valid UTF-8, and any file the arguments never mentioned. Those changes remain invisible and cannot be undone from here. The 1 MiB bound is about latency — the file is read twice per call — not memory.
- The MCP server command, arguments, and environment come from
mcp.jsonand are executed without validation. Configuring an MCP server is equivalent to granting arbitrary code execution. - A
cwdis not a sandbox.
Consequences for the operator: treat adding an MCP server as equivalent to installing a plugin with full user privileges. Prefer AutoApprovedOnly and list tools explicitly. Under the run-commands permission preset the policy resolves to AllowAll, so every discovered tool is callable without a human in the loop.
MCP tool calls made during a run go through the per-run policy; there is no command that bypasses it. (call_mcp_tool, which used AllowAll for a settings-panel "try it" button that was never built, has been deleted — see Known Limitations 14.)
Beyond the workspace boundary, Agent-generated diffs pass through workspace::resolve_for_agent_write, which rejects paths where a write would be equivalent to code execution or credential tampering:
- Directory components anywhere in the path:
.git,.agent-ide,node_modules - Credential filenames:
.env,.env.*,.npmrc,.netrc,id_rsa,id_ed25519 - Credential extensions:
.pem,.key,.p12,.pfx
Matching is case-insensitive and applies only to the path relative to the workspace root, so a workspace that happens to live under a directory named node_modules still works. Without this rule an Agent diff targeting .git/hooks/pre-commit would be applied — in auto mode with no human click — yielding arbitrary code execution on the next commit.
This rule deliberately constrains only Agent-generated diffs. Editing .env yourself from the file explorer goes through resolve_for_write and is allowed, because that is an explicit user action.
The write deny list governs what lands on disk; it says nothing about what leaves the machine. A matching egress rule (workspace::is_credential_path, sharing the credential filename rules above but excluding .git / node_modules) applies to prompt construction:
- If the active file looks like a credential file, its contents and the current selection are withheld from the prompt. The path is still disclosed so the model knows what is open.
build_git_diff_summarydrops hunks belonging to credential files and names which files it withheld.
Not covered: a credential file passed explicitly as a context file, or read by an MCP tool, is not filtered.
maxRunTokenson the LLM profile caps total provider-reported tokens for one run. It is enforced insend_chat_request, the single choke point all provider requests pass through, so it cannot be bypassed by using a different entry point. A configured0is treated as unset.- The meter is stored on the orchestrator, so resuming a paused pipeline continues against the same allowance instead of restarting the count.
usage_is_unknown()distinguishes "the provider reported no usage" from "nothing was spent". Local runtimes and mock endpoints report no usage, so a cap cannot be enforced against them; this is surfaced in the run's action log rather than being reported as zero cost.maxRunSpendMicroscaps the run in money rather than tokens, checked in the samecheck_budgetchoke point and evaluated before the token cap, so an expensive model stops on cost even when the token count looks modest. Amounts are integer micro-USD (1 USD = 1_000_000) and each call's cost is rounded up, so a run cannot accumulate spend that rounds to zero.- Spend needs both
promptMicrosPerMillionandcompletionMicrosPerMillionon the profile. With only one of them the estimate would systematically undercount, so pricing is treated as absent: the action log reportsnot computable (no pricing configured)and the spend cap is not enforced. An undercounting cap is worse than a missing one because the user believes they are protected. - The three spend fields are editable in Settings → Per-Run Spend Cap. The UI takes dollars and converts to integer micro-USD by string parsing (
utils/money.ts), becauseNumber("0.29") * 1e6is289999.99999999994and this value decides when a run is cut off. Creating a new profile clears them rather than inheriting them, so a cap is never enforced using the previous model's prices. - The tool loop is bounded at 12 rounds per stage (
MAX_TOOL_ITERATIONS,agent/executor.rs).
Everything above is authority decided before a run starts. Per-action approval is the other axis: a tool call that is already authorized still suspends and waits for a human, once per action. agent/approval.rs holds it.
- A pending request is a
oneshotchannel, keyed by a backend-generated id. The id is minted by the backend, not accepted from the frontend: it is the key the waiting call is identified by, and letting the caller ofresolve_agent_approvalchoose it would let the UI decide which question it is answering. - Every way of not answering is a refusal: five outcomes, four sentences. Approved (which produces no refusal text at all —
refusal_detail()returnsNonefor it, so no caller can write "approved" into a refusal record), denied by a person, refused by Stop (Cancelled), timed out (DEFAULT_APPROVAL_TIMEOUT, 120 s), and "this run has no approval channel" (Unattended). A dropped channel is treated as Stop, not as a denial — nobody decided anything there. Approval has to be something a person did, never the absence of an objection. The timeout is finite on purpose: an unanswered call would otherwise hold the run's execution lease, and the user would see "the Agent is stuck" rather than "the Agent is waiting for me". - A registered request is always cleaned up, and a cancelled run never gets asked. The registry slot is released by a
Dropguard, soresolve,refuse_all, the timeout and an abandoned waiter (task dropped, runtime shutting down) all take the same path — the registry is app-scoped and never rebuilt per run, so a leaked slot would accumulate.askalso re-reads the run's cancel switch immediately after registering: Stop is two steps (pull the switch, refuse what is pending), and a request that lands between them would otherwise have nobody to refuse it, leaving the UI idle while the tool call waits out its full timeout. - Stop refuses everything pending, and the record says Stop.
stop_agentcallsApprovalRegistry::refuse_all()before taking the orchestrator lock, for the same reasonCancelRegistryexists: Stop must not queue behind the work it is cancelling. The resulting record isbrowser_open_cancelled, matching the suffix the tool-entry Stop gate already uses — writing it as_refusedwith "the user denied this action" would put a sentence in the permanent record that nobody said. The count of refused prompts is also reported as a run-level action-log warning. - The cancel flag is re-checked after approval. The tool-entry Stop gate is taken before the wait, so by the time an approval arrives it can be up to 120 s stale. Stop landing between "decision delivered" and "navigation performed" finds nothing pending to refuse; the second check is what stops the navigation there, recorded as
browser_open_cancelled. - When the backend stops waiting it says so.
agent-approval-closedcarries the request id, and the frontend closes the dialog only if the id matches the one on screen. A dialog left open after a timeout would invite a click that authorizes nothing while looking like it authorized something. resolve_agent_approvalreturns whether anyone was still waiting, and the UI says so. A click that arrives after the timeout returnsfalse, and the store raises the error banner ("that approval arrived too late — the action was already refused"); a failedinvokedoes the same with the transport error. Without that, approving a live request and approving a dead one looked identical: the dialog vanished either way. The dialog is normally already closed byagent-approval-closed, so reaching this state requires that event to have been missed.- The channel is installed by the command layer, as a required argument.
agent_tool_permissions(...)takes theApprovalGatepositionally, so a new run command cannot construct permissions without one. Two paths (continue_agent_pipeline,repair_workspace) instead clone the permissions stored on the orchestrator, and inherit the gate with them; the orchestrator's initial value isread_only(), which has no channel — harmless only because it also grants no browser authority, which is a fact aboutread_only()rather than a guarantee from the signature.
Keyboard reachability is part of this, not polish: the prompt has role="dialog" / aria-modal, focus starts on Deny, and Escape denies. A gate that can only be answered with a mouse does not exist for keyboard users, and a stray Enter must never be an authorization.
Scope today: workspace_browser_open, workspace_browser_read_page, workspace_computer_capture, workspace_computer_click and workspace_computer_scroll. Headless entry points (the CLI) install no channel, so an irreversible action there is refused rather than performed unattended — which is currently moot, since the CLI grants neither browser, page-reading, desktop nor pointer authority. A request the frontend cannot read (an opType a newer backend introduced) is refused immediately using the id from the payload rather than dropped: dropping it left the backend waiting out its full timeout while the UI looked hung. tests/ipc-contract.test.ts additionally fails if the backend asks approval for an op type the frontend's normalizer does not accept, so the mismatch is normally caught before it ships. Remaining single-slot limit: the frontend holds one pending request at a time, and replacing an unanswered one logs a warning — the backend can hold several, but tool calls run serially today, so two concurrent prompts are not reachable.
Deliberately not behind a prompt: workspace_browser_tabs and workspace_computer_windows. Both disclose, but they disclose a list whose scope the allowlist already fixes, and both are called far more often than the three prompted actions — a prompt per call would be the kind of friction that trains people to click Approve without reading, which would cost more than it buys.
Two independent settings control what an Agent run may do, and they are easy to confuse because their values share names. Both are visible in the UI, in different places.
- Agent mode (
AgentMode:suggest|auto) — decides whether the Agent's diffs reach disk without a click, and whether the direct write tool is advertised. Set from the mode switch in the top bar. - Permission preset (
AgentPermissionPreset:read-only|create-files|run-commands) — a shortcut that sets the two fine-grained toggles (allowFileCreate,allowCommandRun). Set in Settings → Agent Permissions, where the toggles can also be changed one by one.
The preset values are deliberately named after what they grant. They used to be
ask / suggest / auto, which collided with the mode's values while meaning
something different — choosing the suggest preset did not put the run in
suggest mode. Two axes have to coexist, so the names now carry the distinction
instead of a paragraph explaining a trap.
The Agent mode is not persisted by the backend — it resets to suggest on every
launch. The frontend session snapshot in localStorage remembers the last
selection and normalizes anything it does not recognize back to suggest, so a
stale value cannot restore a privilege level the user cannot see.
The Agent operates in a "suggest-then-apply" pattern with two modes:
| Mode | Behavior |
|---|---|
suggest |
Produces reviewable diffs only. User must explicitly apply. |
auto |
Applies pending diffs automatically after the pipeline run completes. |
Two modes, not three, because there is exactly one gate: every check in the
backend is written as matches!(mode, AgentMode::Auto). An earlier edit
position sat between them and was byte-identical to suggest — the switch
offered three positions but granted two levels, and a user reasonably reads a
middle setting as "more than suggest, less than auto". It was removed rather
than given a meaning, because every candidate meaning ("may edit existing files
but not create", "may write during the run but not auto-apply") is already
expressed more precisely by the permission toggles below.
Which permission toggles are actually enforced in the backend:
-
allowFileCreate— enforced twice. Inautomode, new-file diffs are held for review instead of being written when it is false, andworkspace_write_filerefuses to create a file that does not exist. -
toolApproval(derived fromallowCommandRun) — enforced, as MCP tool-name gating only. This one follows the preset, not the mode: theread-onlyandcreate-filespresets resolve toAutoApprovedOnly; therun-commandspreset resolves toAllowAll. -
allowCommandRun— enforced. When false, theworkspace_run_commandtool is not advertised to the model and not claimed by the invoker, so there is no Agent path to process execution other than MCP tools. When true, the exposed allow-list is derived by the backend from the project's own declared tasks (package.jsonscripts, Cargo), never from model input, and long-running commands (dev servers, watch tasks) are refused regardless of the list. Every call is written to the action log. -
Agent mode gates direct writes.
workspace_write_fileis advertised only when the run is inautomode. This is not a new privilege level:autoalready applies pending diffs without a click, so writing during the run grants nothing it did not already have. Insuggestthe tool is absent and the model must emit reviewable diffs, which is what that mode promises. Writes still go throughworkspace::resolve_for_agent_write, so.git/,.agent-ide/,node_modules/and credential files are refused on this path too. -
workspace_edit_fileshares that gate and that boundary. Sameallow_writecondition, sameresolve_for_agent_write, so the deny list applies identically. It only edits files that already exist — editing a missing file is refused with a pointer toworkspace_write_filerather than treated as a create, soallowFileCreatecannot be circumvented through it. Matching is exact, after normalising CRLF to LF for the comparison only; a snippet that appears zero times, or more than once withoutreplace_all, is refused rather than applied to a guessed location. The fuzzy fallbacks a reference implementation uses (trimmed lines, flexible indentation, similarity-anchored blocks) are deliberately absent: they can land an edit somewhere the model did not mean, and a wrong edit that reports success is worse than a refusal. The content recorded for undo is the bytes that were on disk, not the normalised copy, and the file is written back with the line ending it already used — otherwise one edit would rewrite every line and the review area would be unreadable. -
workspace_delete_fileshares that gate and that boundary. Advertised under the sameallow_writecondition, deliberately not a separate toggle: a whole-file overwrite can already destroy the contents, so a second switch would only imply that writing is safer than it is. Paths resolve throughresolve_for_agent_write, so the deny list above applies identically. Directories are refused — recursive deletion has a different blast radius, and the undo record is a list of file→content pairs, which cannot rebuild a tree. The tool reads the file before removing it and fails if it cannot, because without those bytes the deletion would not be undoable. The review entry is labelleddeleterather than shown as an emptied file, and undo recreates the file with its exact previous contents. -
workspace_move_fileneeds bothallow_writeandallow_create, and never overwrites. Moving makes a file disappear from one path (a write) and appear at a path that did not exist (a create), so it is advertised only when both grants are present and the invoker does not claim it otherwise. Both paths resolve throughresolve_for_agent_write, so a credential file or anything under.git/can be neither the source nor the destination. An existing destination is always an error — no exception for names differing only in case, because case sensitivity is a per-directory property (WindowssetCaseSensitiveInfo, case-sensitive APFS) and an exception there would silently replace a real file whose bytes no record holds. A case-only rename therefore needs an intermediate name, and the error message says so. Directories are refused, as with deletion. The move itself is a singlefs::rename, so the file is never in two places or in neither; a destination directory created for the move is removed again if the rename then fails. The tool never reads the content, so binary files move as well as text. Undo is a move back rather than a content rewrite, so it restores the file itself and not just its bytes; if the file was edited after the move, undo also writes back the content as of the move. Undo replays a batch in reverse order — the inverse of a sequence of renames is the reversed sequence of inverse renames, and replaying forwards can destroy a file (moveA→B, then create a newA: forwards, the move-back overwrites the newAand the create's undo then deletes it, losing the original). -
The bounded repair loop (
repair_workspace) is gated the same way, for the same reason. Every iteration has to reach the workspace or the re-run checks the old code, so the command is refused outsideautomode instead of being downgraded to a single round. Its fixes go through the normal diff application path (apply_all_diffs), so each round keeps its base-hash staleness check and its undo point; the iteration budget is clamped to 3, and every iteration plus the stop reason is written to the action log. -
Every tool write is recorded as an
applieddiff entry in the review area with the pre-write content, and is covered by an undo checkpoint, soUndo Applyrestores it. A write that would bypass the review area entirely is the failure mode this avoids: the file changes and the user has no way to see what changed. -
There used to be
allowFileDeleteandallowGitActionstoggles here, documented as "not enforced", and they were removed from the UI because an unenforced permission toggle is worse than an absent one. Deletion is now a real Agent capability (workspace_delete_file, 2026-09-13), and it is gated byallow_writerather than by a toggle of its own — see the reasoning above. Git actions remain outside Agent runs, so nothing guards them because nothing needs guarding. -
Permissions are captured as a per-run snapshot when the run starts. Narrowing a permission mid-run does not revoke a tool that was already advertised for that run; stop the run instead.
-
In the CLI, command execution is gated by
--allow-runpatterns instead. Both entry points now share one matcher (services::verification::is_command_allowed), so what counts as authorized cannot drift between them. Read-only workspace tools are attached unconditionally: reading files inside the workspace is not a privilege, it is the subject of the task, and without it the model has to guess theoriginalblock of any edit it proposes — a real DeepSeek run did exactly that and produced diffs that could not apply.--allow-runstill decides whetherworkspace_run_commandexists, and--allow-edit/--allow-createare deliberately not reinterpreted as write-tool grants — they govern whether produced diffs may be applied, and treating them as "the model may write files directly" would be a silent privilege escalation. -
The CLI's write tool has its own flag:
--allow-agent-write, which requires--apply. That requirement mirrors the desktop's Auto-mode gate rather than repeating it: a preview run must leave the workspace untouched, and a tool that writes during a preview would break the only promise preview makes.--allow-createextends the grant to new files only when--allow-agent-writeis also given, so neither flag alone can create anything. Every write lands intool-writes.jsonunder the run's artifact directory — the CLI has no review area, so that file is the only record of which files the model touched. It is written whenever the flag is on, even as an empty array, because "not authorized" and "authorized and never used" are different facts and a missing file conflates them. -
McpToolPolicy::Denyexists but no preset currently produces it, so there is no way to run with MCP tools fully disabled short of removing the servers frommcp.json.
Limits of the command and write tools, stated plainly:
-
A permitted command is still arbitrary code execution by whatever the project declares.
npm testruns the project's test script, which can do anything. The boundary is "commands this project already defines", not "commands that are safe". -
Commands run in the workspace root with the inherited environment. There is no network, filesystem, or environment isolation.
-
The shell differs by platform and the command is translated, not guessed at. Windows runs
cmd /C, everything elsesh -lc. Before a command is executed,command_text::portable_commandrewrites the POSIX-isms thatcmdcannot express but that mean something mechanical: leadingVAR=value(quoted or not) becomesset "VAR=value" && …, single-quoted arguments become double-quoted (single quotes are not quoting incmd, so'src/a b.ts'would arrive as two arguments), and a top-level;becomes&. Escaped quotes inside double quotes are tracked, because miscounting one would hide a top-level;and silently drop the second half of the command. What has nocmdequivalent —$(…), backticks, mixed quoting — and what would change meaning if converted — a quoted%VAR%(cmd expands inside double quotes) or an argument ending in a backslash (cmd reads it as escaping the quote) — is refused with the reason, because a command that succeeds with the wrong value is worse than one that fails, and a confusing shell error costs the model a whole round to diagnose. The tool description also states the platform, the shell and the four rules that actually differ; the record and the UI keep the command as it was written, so a run log never disagrees with the configured task. Nothing is rewritten on POSIX. -
Child output is decoded by encoding, per line, not assumed to be UTF-8. UTF-8 is tried first (modern toolchains emit it even on Windows), and only for the lines where that fails does it fall back to the Windows active code page (
GetACP, mapped for CP936/54936/950/932/949/125x/866/874), withAGENT_IDE_OUTPUT_ENCODINGas an override. Per line, because one pipe can carry both:cargo buildon a Chinese Windows machine interleaves cargo's UTF-8 diagnostics with MSVC's CP936 lines, and judging the whole buffer would re-decode the correct half as GBK. Before this,String::from_utf8_lossyturned every CP936 byte into U+FFFD before the Problems panel parsed it, before the repair prompt quoted it, and before the model read it — so on such a machine a failing build's message was destroyed on the way in. An unknown code page still falls back to lossy rather than guessing a single-byte encoding, because a wrong guess produces plausible-looking text that nobody can spot. -
Command output is truncated to 12,000 characters (tail kept, since failures land at the end) before reaching the model.
-
workspace_write_filereplaces the whole file. A model that writes without reading first can drop content it never saw. The tool description says so, points atworkspace_edit_filefor partial changes, and the pre-write content is kept for undo — but nothing prevents it. -
Batch: Apply All / Reject All
-
Per-file: Apply or reject individual file diffs
-
Per-hunk: Apply or reject individual hunks within a file diff
-
Undo:
Undo Applyrestores every file touched by the most recent apply to its pre-apply content, up to 20 levels. Files that did not exist before are deleted rather than left empty. The affected diffs return topendingwith a re-stampedbaseHash, so they re-enter the review queue and can be applied again. Snapshots live only in backend memory — they are not persisted and do not survive an app restart, and they are never sent over IPC.
Safety mechanisms during diff application:
- Outside-workspace paths are rejected.
- Missing original content (empty hunks on edit diffs) is rejected.
- Ambiguous original matches (hunk text appears more than once) are rejected — the file is not modified.
- New-file hunks that would overwrite an existing file are rejected.
- Mixed new-file and edit hunks in the same diff are rejected.
baseHashvalidation rejects stale edit diffs if the file content hash no longer matches the hash recorded when the diff was generated. An edit diff carrying no hash at all is rejected too: every diff-producing path stamps one, so a missing hash means the target could not be read at generation time — there is no basis for judging whether it changed since.- Whitespace-insensitive hunk matching is the last resort, fires only when exactly one window matches, and takes its indentation from the file rather than from the model's quote.
- Restored changes handed back by the frontend (
adopt_restored_diffs) get no extra authority. The backend keeps diffs in memory only, so after a restart the renderer returns the copies it persisted. They are accepted only inpending/partial/failed/stalestate — neverapplied/rejected/reverted, which are history — duplicate ids are ignored, and the recordedbaseHashis kept, never re-stamped: re-stamping would record the file as it is now and make the staleness check vacuous. Every adopted diff then faces the same checks as a generated one at apply time: workspace path resolution,baseHashmatch, ambiguous-match refusal, new-file overwrite refusal. - Partial-apply failures are reported structurally:
ApplyDiffsResult { applied, failed }— each failed diff includes the diff ID, file path, and error message. The failed file content is not modified. - Failed hunks within a multi-hunk diff prevent the entire file from being written (atomic per file).
Diff provenance tracks:
- Protocol (
agent-changesor legacy markdown diff) - Operation (edit or create)
- Schema version
- Change index within the model output
- Rationale for the change
- Source role and source stage (e.g.,
coder/Coder) - Regeneration chain (
regeneratedFromDiffId,regeneratedFromHunkIndex)
What is sent to the configured LLM provider, and only to it:
project_memory—AGENTS.mdverbatim, capped at 8000 charactersactive_file_content— the full file inFullmode, a 24 000 / 16 000 character excerpt inBudgeted/Focused, an outline inCompactselection— verbatimgit_diff— working-tree patch text, capped at 24 000 charactersproject_tree— up to 160 entries, depth 4open_files— paths onlyide_runtime— the IDE's current state as the user chose to attach it: up to 20 problems, 4 000 characters of terminal output, 8 warn/error log lines, and 4 000 characters from a failed check command. It used to be concatenated onto the prompt by the frontend, which meant it was sent but never counted; it is now a context section like the others, so it appears in the estimate panel and is subject to budget trimming.
Every context source is on by default. The per-run toggles let the user turn each off, but the shipped default sends all of them.
Filters that apply:
- Credential files are withheld as described in the egress section above.
- The tree listing skips
.git,node_modules,target,dist,.DS_Store,Cargo.lock,package-lock.json. This filter applies to the listing only, not to file contents.
Other guarantees:
-
No telemetry, analytics, crash reporting, or phone-home of any kind.
reqwestis used only for LLM requests. -
The two hardcoded URLs (
https://api.openai.com/v1,https://api.deepseek.com/v1) are overridable defaults, not fixed destinations. There is no scheme or host allow-list on the configured endpoint. -
Git remote URLs come from the repository's own config, not from the app.
-
Agent output is never rendered as HTML:
ReactMarkdown skipHtmlplussanitizeMarkdownbefore rendering. -
API keys are masked in IPC responses, action logs, and the UI. The exception is
reveal_llm_api_key. MCP tool arguments are redacted by key name only, and MCP tool results are not redacted at all — see the credential section.
- Terminal cwd is scoped to the workspace root.
- Commands run with the user's local permissions (no elevation or privilege escalation).
- PTY lifecycle is managed by the Rust backend:
spawn_terminal,write_to_terminal,resize_terminal,kill_terminal. - Kill terminates the PTY cleanly by signaling the reader loop.
- Windows
\\?\verbatim path prefixes are stripped before passing cwd tocmd.exe(which rejects UNC paths). - Multi-session UI supports session tabs, new/close/restart, but all sessions are bounded by the workspace root.
- Browser preview mode shows a disabled-state message instead of attempting PTY access.
- All Git operations resolve paths through the workspace service and use
git2::Repository::discoverto locate the repository from the workspace path. - Available operations: status, staged/worktree/all diff, stage/unstage/discard, commit, branch checkout/create, remote branch checkout/tracking, fetch, fast-forward-only pull, push, upstream/ahead/behind display, conflict detection, and conflict resolution (accept current/incoming/both).
- One-shot HTTPS credential inputs for remote actions: credentials are prompted once per operation and are not persisted by default.
- Optional OS-stored HTTPS credentials via
credentials::git_credential_refandcredentials::store_secret— when the user opts in, the remote token is stored in the OS credential store and reused for future remote operations. - Force-push and destructive operations (discard, revert, reset) are available but should require explicit confirmation (confirmation UX for destructive actions is still being improved).
- Conflict resolution is presented in the UI before any auto-resolution is applied.
The Agent CLI (agent_cli) is scoped as a headless automation runner. Security controls:
- Workspace boundary: shared
workspace::resolve_existingandworkspace::resolve_for_writechecks. --allow-runauthorization: repair loops require explicit--allow-run <pattern>for each command that will be re-executed. Patterns support:- Exact match:
npm test - Prefix wildcard:
cargo * - Trusted all:
*
- Exact match:
--max-iterations: bounds the number of repair loop iterations.--timeout-seconds: bounds how long a single command or the overall run can take.--max-output-bytes: limits captured command output size.--max-diff-files: limits the number of files in a single Agent diff proposal.- Stable exit codes:
0success,1internal error,2invalid input,3changes proposed,4checks failed,5apply failed,6provider failed,7precondition failed,8cancelled.
Known gaps:
-
The Agent write deny list is shared with the desktop app, because both go through
diff_apply. On top of that the CLI enforces--deny-path: any generated diff whose path matches a pattern is refused withExitCode::PreconditionFailed(cli/mod.rs), before anything is written. -
Operation-level flags exist for create, edit and delete (
--allow-create/--allow-edit/--allow-delete), but only delete is independent.--applyitself sets create and edit (allow_create: args.allow_create || args.apply, same for edit), so "edits but no new files" is not expressible on the CLI. The whole permission check also only runs on the apply path, so without--applythese flags have no effect in either direction. -
--allow-gitis accepted and logged but enforces nothing. -
MCP tools are not exposed to the CLI at all today.
Ordered by how much they would matter to an operator. Each was confirmed by reading the code, not inferred.
-
MCP tools are unconstrained. They bypass the workspace boundary and the Agent write deny list entirely. Adding an MCP server is equivalent to granting arbitrary code execution. This is the single largest gap. Narrowed, not closed: every call is recorded in the durable external-action log, and a change to an in-workspace file the arguments named now reaches the review area as an applied diff with an undo checkpoint. Outside the workspace, over 1 MiB, non-UTF-8, or never named in the arguments — still invisible and still not undoable.
-
A plaintext API key can still persist in
~/.agent-ide/config.jsonif keyring migration ever fails — the file is left unrewritten rather than losing the user's only copy. It is no longer used: the keyring is read first, and the plaintext is ignored unlessAGENT_IDE_ALLOW_PLAINTEXT_KEYis set, with the Settings panel labelling it as plaintext either way. No file-permission hardening. -
Repository-wide Git write operations escape the workspace boundary when the workspace root is a subdirectory of a larger repository, because
Repository::discoverwalks upward. Affectscheckout_headandgit_commitwith no file list. The git-diff context section is now pathspec-scoped and no longer affected. -
Workspace-local language server binaries are executed in preference to
PATH, so opening an untrusted repository runs code it supplies. -
MCP argument redaction keys on field names, so a secret passed under a key that does not look secret is still written to the action log. Tool results are logged without redaction.
-
Broad
fs:allow-read/fs:allow-write/fs:allow-mkdirremain incapabilities/default.json. The unscopedshell:allow-spawnandshell:allow-executehave been removed — no first-party frontend code importsplugin-shell, so they were pure attack surface.shell:allow-openis retained for opening external links. -
run_project_commandpasses the command string tocmd /Corsh -lcwith no allow-list and no escaping. Two callers reach it: the GUI task runner andverify_workspace, both driven by commands the user supplies. The CLI additionally runs commands taken from the workspace's ownpackage.jsonscripts autonomously. -
Recursive traversal follows symlinks.
search_recursiveandcopy_dir_recursivere-check nothing per entry. -
resolve_for_writedoes not canonicalize the final component when the target does not exist, so a symlink created between check and write is not caught (TOCTOU). Not tested. -
Cancellation is cooperative — a shared
AtomicBoolchecked in the request and streaming paths. There is no transport-level abort. -
A token cap cannot be enforced against providers that report no usage (local runtimes, mock endpoints). This is surfaced rather than silently treated as zero.
-
Hunk matching is textual, not AST-aware. Ambiguous matches are rejected rather than guessed, and
baseHashcatches stale edits, but line-offset tolerance is not implemented. -
macOS and Linux credential backends are unvalidated at runtime. Windows is verified end to end. Linux and macOS CI jobs now attempt the round trip — Linux under
dbus-run-sessionwithgnome-keyring, macOS against the login Keychain — but neither result has been confirmed yet. Linux's first run failed before reaching the tests, on an unrelated RGBA icon problem that has since been fixed, so both stay listed as unvalidated until a run gets that far. -
Resolved by deleting it. It was registered withcall_mcp_toolhas no UI caller.AllowAlland no frontend entry point, so the permissive policy was reachable only over IPC; the alternative — building a "run this tool" button — would have shipped an arbitrary MCP invocation surface nobody had asked for.tests/ipc-contract.test.tsnow fails on any command that is registered and never invoked, so this cannot come back unnoticed. Three other unreachable commands went with it:update_llm_config(superseded bysave_llm_profile, and it silently discarded every per-profile setting),file_exists, anddisconnect_mcp_servers. That last one exposed a real gap rather than a redundancy: MCP servers were only ever stopped by re-discovery, so disabling or deleting a server in the panel left its process running, and after removing the last one there was no in-app way to stop anything.save_mcp_confignow stops exactly the servers that should no longer be running (McpRegistry::retain_configured) — see ROADMAP 114.
If you discover a security vulnerability, please report it by opening a private issue or contacting the maintainers directly. Do not disclose vulnerabilities publicly before a fix is available.
Include as much of the following as possible:
- Description of the vulnerability and its impact
- Steps to reproduce
- Affected versions
- Any proposed mitigations
Security-related issues will be prioritized for review and resolution.