Skip to content

Bounded changes (0.17, planned)

This page is generated from docs/lld/bounded-changes.md in the repository.

Status: the user approved this design (2026-10-10). Done: T7, the strict profile (patch 0184); T8, the audit checkpoint (patch 0185, with its failure fixes in 0186); T6, job write scopes (patches 0189, 0190); T9, the shared budget (patches 0191, 0192). Second review (2026-10-11): T5 never falls back to direct mode and never applies an incomplete change list; T9 keeps a reservation as spent when a request fails with no reported usage. T10 is implemented by this patch (labels and the 60-query LSP measurement); T5 remains planned. This is the design for review tasks T5–T10 (external review of 0.16.1, 2026-10-10). The patch plan is at the end. Each section has decisions, a threat model, a default and tests.

The review found one main gap: a command in the OS sandbox runs with no question, and it can change any file in the project. Edits ask first, but a command such as sed -i, npm install or a build script can change the same files with no diff and no yes. /undo can restore them later, but only when undo is on, and only after the change.

The review also found five smaller gaps:

  • A scheduled job’s approval list limits the tools (edit_file(src/**)), but its commands can write anywhere in the worktree.
  • A user cannot turn the host fallback off with one value. The team policy has requireSandbox, but without a sandbox Garuda still starts, and every command is then denied, one at a time.
  • /audit verify does not detect lines cut from the end of a file, or a deleted file.
  • Each subagent has its own token budget. Parallel children can spend more than the parent has left.
  • Java and Python references come from identifier matches, not from resolved symbols. The model and the user do not know that.
Task Change Default Where
T5 Staged commands: a command writes to a staging copy; Garuda shows the change and asks; a no discards it. Off. "commands": { "mode": "staged" }. src/sandbox/, src/permissions/, src/tools/bash.ts
T6 Job write scopes: a job’s commands can write only in the folders that the user approved. On for new jobs. An old job without a scope says “the whole worktree” in its approval text. src/jobs/, src/cli/jobCommand.ts
T7 Strict profile: OS sandbox required, no host fallback, no outside_sandbox. Off. "profile": "strict" in a policy file or the project’s settings. src/permissions/policy.ts, src/sandbox/index.ts, src/tools/bash.ts
T8 Audit threat model in the docs, and an optional checkpoint of each file’s chain head. Docs: always. Checkpoint: off. src/audit/logger.ts, /audit verify
T9 One token budget for the parent, its children, retries, compaction and the final answer. On (same limits; children can no longer spend past them). src/loop/, src/agents/
T10 Java and Python references marked approximate; precision and recall measured. On (labels only). src/knowledge/, src/tools/codeTools.ts

The rule “a feature without a measured gain is off by default” applies to T5, T7 and the T8 checkpoint. T6, T9 and T10 correct what an existing feature promises, so they are on.

Section titled “T5: staged commands (consent for command effects)”

With "commands": { "mode": "staged" }:

  1. Garuda keeps a staging copy of the project for the session.
  2. A command in the sandbox runs in the staging copy. The real project is read-only for the command.
  3. After the command, Garuda lists what changed in the staging copy.
  4. No change: no question. Most commands (tests, git status, grep, a build into an ignored folder) change nothing that Garuda shows, so they do not ask.
  5. A change: Garuda shows the diff and asks: apply, or discard.
  6. Apply: Garuda copies the changed files into the project; with undo on, /undo restores them. Discard: Garuda restores those files in the staging copy from the project. The project does not change.
  7. The model gets the command’s output and one line: “The user applied the changes” or “The user discarded the changes: the project did not change”.

outside_sandbox: true is not staged: it asks first unless an allow rule or a session answer covers it, as today. In "mode": "direct" (the default) nothing changes.

No silent fallback (second review). When the user selects staged mode, Garuda sets up the staging copy at the session start, or stops with the reason (as the strict profile does). It never runs commands in direct mode instead. A staging copy that fails later (disk full, a lost overlay) stops each command with an error until the session ends; it does not switch to direct mode.

An incomplete scan never applies (second review). When the list of changes is incomplete (the 50 000-file limit, a file that cannot be read, a walk that fails), the question says so and offers only “discard”. Apply needs a complete list.

sequenceDiagram
  participant M as Model
  participant G as Garuda
  participant S as Staging copy
  participant P as Project
  participant U as User
  M->>G: bash "sed -i s/a/b/ src/x.ts"
  G->>S: run the command in the sandbox (project read-only)
  S-->>G: output and the changed paths
  G->>U: diff of src/x.ts - apply or discard?
  U-->>G: apply
  G->>P: copy src/x.ts (after the conflict check)
  G-->>M: output and "The user applied the changes"
Platform Backend Path that the command sees
Linux, bubblewrap 0.9 or later, and a kernel that allows overlayfs in a user namespace (5.11 or later) Overlay: --overlay-src <root> --overlay <upper> <work> <root>. Writes go to upper. The project’s own path.
Linux, other A copy (cp --reflink=auto) in ~/.garuda/staging/, bound at the project’s path with --bind. The project’s own path.
macOS (APFS) A clone (clonefile, cp -c -R) in ~/.garuda/staging/. Seatbelt cannot move a path, so the command runs in the clone. The clone’s path.

On all platforms the sandbox policy of a staged command has the real project in the read-only list. A tool that writes to an absolute path in the project (a virtual environment, a link in node_modules) fails with “Read-only file system” or “Operation not permitted”. The change cannot reach the project past the question.

The staging copy lives for the session. Build outputs in ignored folders stay in it, so the next command sees them. Garuda’s own edits (edit_file, write_file) go to the project after their question, as today, and Garuda copies the same files into the staging copy before the next command.

  • Changed, added and deleted files that git does not ignore: a diff, as for an edit.
  • Sensitive files (the list in src/permissions/sensitive.ts) by name, also when git ignores them.
  • Other ignored files: one line per top folder with a count (“target/: 212 files”). These stay in the staging copy and are not applied (see open question Q1).

Garuda records the hash of each changed path in the project before the command. Before it applies, it checks the project again. A file that changed in between (the user edited it) is a conflict: Garuda applies nothing and shows the paths. The staged change stays for one more question.

Threat Control
A command changes project files with no question. The project is read-only for the command; only an apply copies files, after a yes.
A command writes through a link in the staging copy to the real project. The real project is read-only in the sandbox policy, so the write fails. Garuda copies regular files only and refuses links that point outside the root.
A command hides a change in an ignored folder that a later host program runs (node_modules/.bin). Ignored changes stay in the staging copy; they never reach the project in this design.
The diff hides text with control characters. The same display as the edit question (hidden characters shown).
A large change hides one bad line. The question shows the counts per file and allows “view”; per-file selection is open question Q2.
  • A clone or copy at the session start: measured before release on Garuda’s own repository and on a 20 000-file Java repository. Target: less than 2 s on APFS and with reflink. A plain copy over 2 GB stops the session start with a message (no fallback to direct mode).
  • The list of changes uses the snapshot walk of /undo (src/undo/snapshots.ts): the same excludes and the 50 000-file limit.
  • A command writes a tracked file; the user discards; the project file is unchanged (negative control: in direct mode the file changes).
  • The user applies; the project has the new content; with undo on, /undo restores it.
  • A command writes to the real project’s absolute path; it fails; the project is unchanged.
  • A command deletes a file; the question lists the deletion; apply deletes it.
  • A command that changes nothing: no question.
  • An ignored build output stays in staging and the next command sees it; the project has none.
  • A conflict: the user changes the file during the command; apply refuses.
  • outside_sandbox still asks first (without an allow rule) and is not staged.
  • Staging cannot be set up (a fake backend that fails): the session does not start; no command runs in direct mode (negative control: a direct-mode session starts).
  • An incomplete change list (a fake walk at the file limit): the question offers only discard.
  • Live test on macOS (clone) and Linux (overlay, and copy with an older bwrap).

src/staging/copy.ts: StagingCopy.create(root, { executor, home }) makes the copy in ~/.garuda/staging/<root hash>-<pid>-<random> (folder 0700). A probe on one small file picks the method: cp -c (clonefile, macOS), cp --reflink=always (Linux), else a plain cp -a. A plain copy over 2 GB (du -sk first) throws StagingError with the reason; a failed copy throws and leaves no folder. Copies of ended processes for the same project are removed at the next start. remove() deletes the copy at the session end. The copy is complete: dot files, .git and links as links. Not yet used by commands (0212). Measured here (Linux, no reflink): Garuda’s repository, plain copy, 0.09 s.

  • Setting. "commands": { "mode": "direct" | "staged" } (default direct). Runtime.create starts StagedCommands (copy and store) or throws StagingError: no OS sandbox, a copy over the limits, more than 50 000 files. The session does not start; no direct-mode fallback. A scheduled job (unattended, or with a write scope) keeps direct mode in its own worktree.
  • Executor. StagingExecutor wraps the OS executor. It stages run() calls that are sandboxed with the root writable (the bash tool, and hooks); outside_sandbox, read-only roots (plan mode, language servers) and Garuda’s own host git pass through; start() (MCP and language servers) passes through; daemons is undefined (off). Staged runs go one at a time.
  • Placement. bubblewrap: ExecPolicy.mounts binds the copy over the root after the writable paths, so .git/hooks, .garuda/ and the denied paths stay protected inside the copy, and an absolute path to the project reaches the copy. Seatbelt: stagedPolicy(..., "move") makes the copy the working root, moves the root’s paths to it, and denies writes to the project (Q3).
  • Lists. src/staging/staged.ts runs git with a store next to the copy (<copy>.git, outside every sandbox write path) and one index per work tree. prepare() brings the copy to the project’s tree with read-tree -m -u (this also carries Garuda’s own edits and the user’s edits into the copy); collect() lists the changes (.gitignore applies, as for /undo).
  • Question. Title “The command changed files”: the command, the file list and the diff (cut at 60 000 characters; the list is complete). Apply or Discard. An incomplete list: Discard only. An error or Ctrl-C at the question discards.
  • Apply. All or nothing. A path that also changed in the project since prepare() is a conflict; a link out of the root is refused; then nothing is applied and the changes are discarded (as built, a conflict does not keep the change for another question: the model gets a note to run the command again). Otherwise read-tree -m -u moves the project from its tree now to that tree plus the command’s changes (modes, links in the root and deletions included). The turn’s undo snapshot covers it.
  • Not applied, shown (0213). Before the command, a marker file in the store; after it, find -newer in the copy lists files written outside the tracked changes (ignored files and .git, at most 100 000 names). The question shows them as a count per top folder (“build/: 212 files”), and sensitive ones by name. Deletions of ignored files are not shown.
  • System prompt (0213). In staged mode the model learns that the user applies or discards a command’s changes, and that a discard leaves the project unchanged.
  • Measured (Linux, ext4, no reflink). A generated project with 20 000 files (79 MB): plain copy 0.4 s; session start with both first trees 3.0 s (git hashes every file twice, once); then per command about 0.1 s before (prepare) and 0.08 s after (collect). Garuda’s own repository: copy 0.09 s.
  • macOS run (0215). The user’s macOS run found test faults, not product faults: BSD sed -i needs a suffix argument, so the tests now write with printf; a write to the project’s absolute path fails on macOS (the command runs at the copy’s path), so that test checks the platform; the plain-copy limit test uses fastCopy: false instead of a wrong platform.
  • Overlay backend: not built. It would save only the copy (0.4 s above); the first trees and the per-command lists cost the same, and overlayfs does not allow changes to the project (its lower layer) while a command runs. A proposal for later, if a large project needs it.
  • Q1. Ignored files stay only in the staging copy. They are never applied.
  • Q2. All or nothing first. Per-file selection can come later.
  • Q3. On macOS the command runs at the clone’s path. Staged mode works on macOS and Linux.

A job file gets one new field, writeScope: a list of folders in the worktree, for example ["src", "test", "target"].

  • /schedule proposes the scope from the plan: the folders of the files that the plan names, plus the build output folders of the language profile (target, build, .gradle, dist). The approval question shows it.
  • garuda run builds the sandbox policy with the worktree read-only and only the scope folders (and temp, caches, linked folders) writable.
  • edit_file and write_file outside the scope are denied, also when an allow rule matches.
  • After the turn, the job lists files changed outside the scope (for example by git itself). Any such file marks the job as failed with “changes outside the approved scope”; no commit.
  • A job file without writeScope (created before 0.17) runs as today, and the run’s first line says “Commands may write anywhere in the worktree.”
  • Entries are folders (src/) or single files (README.md), not only folders: Garuda’s own edits run in Garuda’s process, so the engine can check a file; for commands, bubblewrap binds an existing file as well as a folder. A new file in the root cannot be made by a command (bubblewrap binds only existing paths); an edit can make it when the scope names it.
  • The engine denies an edit outside the scope only when a rule or a session answer would allow it; an edit that no rule allows gets the usual job denial. Both are in the report’s denied calls.
  • The sandbox applies the scope after writePaths and before the denied paths (see sandbox.md), so a root inside /tmp stays read-only, and a policy-denied file in a scope folder stays denied.
  • Second review (0190): a job never runs a command outside the sandbox (the engine denies outside_sandbox before any rule, also an allow rule); the engine and the write tools check the place that a path reaches through symbolic links, and the tools check again just before the write; the job’s test command has the same scope as every other command (the preview says so).

bubblewrap binds folders. Seatbelt could take a regular expression, but the two platforms must enforce the same scope. A folder list is what both can enforce, and what the user can read.

Threat Control
A job command changes a file that the user did not approve (CI config, a build script). The OS sandbox refuses the write; the post-check catches any other path.
The model edits the job file to widen its scope. The job file is in the checkout’s .garuda/jobs/, outside the worktree; the job’s commands cannot write there.
A scope of . Refused: a scope entry is one or more folder names, not the root, not .., no link.
  • A job command writes outside the scope; the write fails; the job reports it (negative control: without the scope the write succeeds).
  • An edit outside the scope is denied with an allow rule present.
  • An old job without writeScope runs and prints the whole-worktree line.
  • Scope validation: ., .., absolute paths and links are refused.
  • Live test job on macOS and Linux.

"profile": "strict" in the managed policy, in ~/.garuda/policy.json, or in the project’s .garuda/settings.json. A project can turn strict on, never off: the strictest source wins.

Strict means:

  1. The executor must be the OS sandbox. Without one, Garuda stops at startup with the reason and the fix (install bubblewrap; or remove the strict profile). No host fallback (the mode where commands run on the host and ask). executor: "host" is an error.
  2. The bash tool has no outside_sandbox parameter, so the model does not see it. A call that still sends it is denied.
  3. !command in the chat runs in the sandbox only.
  4. garuda acp: session/new fails with the same message, as a plain error (not “auth required”).

As built: a strict policy also has requireSandbox: true. The strict bash schema rejects unknown fields, so a call that still sends outside_sandbox fails the input check. See permissions.md.

Strict does not change the network rules or the edit questions.

Threat Control
A machine without a sandbox runs commands on the host after one tired yes. No host executor and no outside_sandbox in strict mode.
A cloned project removes strict mode. Only a policy file or the user can set it; a project can only add it.
  • With a fake probe that finds no sandbox, strict startup exits with the message (negative control: without strict, Garuda starts with the host notice).
  • The bash tool schema has no outside_sandbox in strict mode.
  • A project file with "profile": "default" does not turn off a policy’s strict.

T8: audit threat model and checkpoint (done, 0185, 0186)

Section titled “T8: audit threat model and checkpoint (done, 0185, 0186)”
Change to a log file Detected today With the checkpoint
A line changed Yes Yes
A line inserted or removed in the middle Yes Yes
Lines cut from the end No Yes, back to the last checkpoint
A whole file deleted No Yes, when the checkpoint lists it
The whole chain rewritten by someone with the user’s rights No No, unless the checkpoint is outside the user’s reach (a command, below)

A sandboxed command cannot write ~/.garuda, so the agent’s own commands cannot change the log. The user, and any program that runs as the user, can.

With "audit": { "checkpoint": true } (policy files only):

  • After each line, Garuda writes the line’s seq and hash to ~/.garuda/audit/<project>-<hash>/checkpoints/<file>.json (atomic write, 0600). One checkpoint per log file, so parallel processes never write the same checkpoint.
  • /audit verify compares each file with its checkpoint: fewer lines, or another line at the checkpoint’s seq, is a failure; a checkpoint without its file is a deleted file.
  • Not built (later, if a team asks): a checkpointCommand that sends the head to the team’s own service, outside the user’s reach.
  • Cut the last line; verify with the checkpoint reports it (negative control: without the checkpoint, verify says “ok”).
  • Delete a file; verify reports the missing file.
  • The checkpoint file holds no target or reason text.

T9: one token budget for the agent tree (done, 0191, 0192)

Section titled “T9: one token budget for the agent tree (done, 0191, 0192)”

The parent’s tokenBudget counts its own calls, and adds a child’s usage after the child ends. Each child has its own budget (150 000 by default), whatever the parent has left. Two parallel children can each spend their full budget when the parent has less left.

A Budget object, made per turn and shared by the parent and all its children:

  • reserve(tokens) before each model call: context tokens plus maxTokens. Not enough left: no call, and the run stops with token_budget.
  • settle(reservation, used) after the call returns the unused part, but only when the provider reported the usage of that request.
  • This applies to successful responses too. Missing, partial or invalid OpenAI-compatible usage is marked usageReported: false; the full reservation stays charged. Context estimates are not added to reported session totals or to that charge. The existing unreported records persist it.
  • Failures and cancellation (second review). A request that fails, times out or is cancelled with no reported usage keeps its whole reservation as spent: the provider may have charged for it. A request with a reported usage (also a cut-off stream) settles to that usage. Releasing a reservation only because a request failed would understate the spend.
  • A child gets min(its own limit, what is left). JavaScript runs one reservation at a time, so parallel children cannot both take the same tokens.
  • Retries, compaction and the child’s wrap-up call reserve in the same way.
  • The parent keeps a floor for its own final answer (context tokens plus maxTokens of its last call). A child cannot reserve into that floor.
  • Three parallel children with a small parent budget: the sum stays inside the cap (negative control: today the sum goes over).
  • A child stops early and its unused tokens return to the parent.
  • The parent still gets its final answer when the children used the rest.
  • A request that fails with no usage keeps its reservation; one with a usage settles to it.
  • A cancelled child keeps its open reservation as spent.
  • src/loop/budget.ts: TokenBudget (reserve, settle, child, setFloor) and requestTokens.
  • The input estimate is the larger of the whole current request (system prompt, conversation, client and server tool definitions, thinking settings) and the last measured context plus new messages. Both character estimates use about 4 characters per token; the caller adds the output limit. This covers changed instructions and tools on resume without discarding a larger measured allowance. It needs no journal migration. A provider that counts more input tokens than the estimate can pass the cap by that difference; the README lists it as a limit.
  • A refused call is not a step. A child’s wrap-up call reserves the whole conversation plus the wrap-up note.
  • Behaviour change: a small tokenBudget now stops a run before a call that may not fit, not after the call that passed it.

Five gaps, each with a regression test in test/budget.test.ts:

  • The child’s wrap-up request is built first and reserved like a main call (system prompt and tool list included), also with zero completed steps.
  • Every provider request reserves: CallMeter and StreamOptions.meter; the OpenAI-compatible client’s HTTP retries and the batch-to-normal switch call meter.retry(); the Anthropic SDK gets maxRetries: 0 under a meter (the loop retries, also a 429).
  • The floor for the final answer is the bound of the next request: the last reservation, the output limit, and a bound per tool result (results are capped at 100 000 characters, a subagent’s answer at 20 000).
  • tokenBudget stays a session limit: charges with no reported usage are kept in the session (unreported records) and count in later turns and after a resume.
  • /compact reserves its summary request in the session’s budget.
  • Metered Anthropic batch creation sets maxRetries: 0, just like streaming. Garuda’s loop reserves each resubmission; status polling and cancellation are not generation submissions.
  • Known child charges are saved at settlement as child_debit records, attributed to the direct child’s report id. Reports replace only the amount they cover. Pending charges remain counted after cancellation, failed parallel batches and resume; old journals start with an empty map. This protects the allowance without double-counting nested reports or discarding partial ones.
  • Read-only parallel batches await all started calls before leaving the turn, keeping settlement tracking active even when one child fails. No new request starts after an abort.
  • Results are capped after formatting and hooks, on every result path, and at the loop boundary for custom/replay runners. Subagent metadata fits inside its 20 000-character result cap.
  • The parent floor uses the current request estimate, the exact empty-result message structure, worst-case JSON escaping (six characters per string character), and the output limit; plus the summary request when a compaction is due before the next request (Codex review of 1370184). This conservatism can refuse child work earlier at small budgets; the parent can still answer.
  • Compaction reserves max(previous context, full summary input estimate) + 4096, including its system prompt. The approximate provider-token-count limitation is unchanged.

Tests and local validation are recorded in the budget review record.

T10: approximate code-intelligence results

Section titled “T10: approximate code-intelligence results”

Java/Python experts return approximate: true on the result envelope (Go, Rust and plugin experts are marked too, since the Codex review of 1370184). The index propagates it through references, callers and impact, including empty results and unresolved symbols. Model output starts with an approximate warning; chat adds ≈. Impact risk is labelled heuristic.

Implementation refinement: this replaces the proposed per-hit exact field. A result envelope also warns on zero hits and derived results, without asserting exact: true for every TypeScript lookup or breaking existing plugins. Absence of the flag is not a perfect-accuracy claim.

Thirty symbols in a generated shopkit Java project and thirty in Python are frozen in JSON, with reference locations captured from jdtls 1.61.0 and pyright 1.1.406. Tests score unique path:line sites, excluding the selected declaration, and reject a lower precision/recall or stale evidence. Fixed floors in the test code (the T10 values) apply on every run, also during a refresh, and a refresh variable set in CI fails (Codex review of 1370184). Only one capture is stored (a second, not stored, agreed at the time). The supplementary manual alias sample is separate.

See the evidence record for denominators, limits, toolchain and reproduction. Java precision/recall: 61.1%/91.7%; Python: 71.4%/93.8%. These synthetic, template-based measurements do not establish real-repository accuracy.

  • Java/Python metadata reaches model tools and chat, including missing/empty results.
  • TypeScript results in TS-only and mixed projects do not inherit the warning.
  • Truncation keeps the warning; malformed/stale/incomplete oracle evidence fails validation.
  • All 60 queries reproduce the recorded report; the scorer handles duplicates and empty predictions.
  • A report under a fixed floor fails, whatever the published file says; a refresh in CI fails.

CHANGELOG; this file (status); docs/architecture.md and docs/hld.md rows; docs/lld/sandbox.md (T5), jobs.md (T6), permissions.md (T7), agents.md and runtime-and-loop.md (T9), knowledge.md (T10); the site’s security page (T5, T7, T8). Each public text keeps the exact claims from T1.

Patch Task Content
0183 T4 This design, the architecture and HLD rows (planned). Done.
0184 T7 Strict profile. Done.
0185 T8 Audit docs and the checkpoint. Done.
0186 T8 Checkpoint failure handling (second review).
0187 T1 Exact claims, second pass (second review).
0188 T4 This update: no fallback in T5, failures in T9, the new order.
0189 T6 Job write scopes. Done.
0190 T6, T1 Third review: scope bypasses (outside_sandbox, links, the test command); claims.
0191 T9 The shared budget.
0192 T9 Review of 31aab17: wrap-up request, provider retries, the floor, session charges, /compact.
0193 T10 Approximate labels; frozen 60-query LSP oracle and measured precision/recall. Implemented; validation in the evidence record.
0194 T5 Staging backends and the change list (no question yet).
0195 T5 The question, apply, discard, conflict check, undo.
0196 T5 Docs, the live tests on macOS and Linux, the measured costs.

The second review asked for T6 and T9 before any wider unattended pilot, so they come next. T5 stays opt-in, and comes last, after its apply, discard, conflict and platform tests pass.