Skip to content

fix: the agent asks for a folder grant rather than leaving the sandbox command by command - #513

Merged
Yiannis128 merged 5 commits into
masterfrom
feat/add-directory-nudge
Nov 10, 2026
Merged

Yiannis128 merged 5 commits into
masterfrom
feat/add-directory-nudge

Conversation

@Yiannis128

@Yiannis128 Yiannis128 commented Nov 10, 2026 •

Copy link
Copy Markdown
Collaborator

A chat working in a repository outside the workspace set `unsandboxed` 105 times, raised 72 approval banners, had 12 commands refused in never mode, and never called `add_directory`. The model did what its descriptions told it to: `unsandboxed` said to call again with it set whenever a command had to write elsewhere, and `add_directory` only fired on a folder the user had named.

Descriptions

  • `bash`'s `unsandboxed`: work that keeps writing in one folder (a repository, a worktree, a build directory) is `add_directory` once. `unsandboxed` is for one-off needs: installers, container tools, setuid programs.
  • `add_directory`: fires when the work is in a folder outside the workspaces, whether the user named it or the task led there, and grants the folder the work is in (the repository root, say).

What the sandbox refused (`lib/domain/sandbox_denials.dart`, read in `LocalShell`)
Neither enforcer reports a denial:

  • macOS: Seatbelt logs nothing for a `sandbox-exec` profile nested under another sandbox. I tried `(debug deny)` and `log show --info --debug`, and `(with report)` is refused on a deny. A `log show` over the run's window also costs 1–2s per failing command.
  • Linux: Landlock logs nothing, and the helper has already exec'd the command by the time the write happens.

Both leave the kernel's errno in the output: Seatbelt's EPERM ("Operation not permitted") and Landlock's EACCES ("Permission denied"). The output is read where the confined run ends, in `LocalShell`'s one-shot run, so every run under a sandbox reports them on its `ShellResult`. Output with neither errno returns before anything is resolved. A path counts as refused when it:

  • is the quoted string on the line, or the field just before the error;
  • contains a slash;
  • lies outside `CommandSandbox.writes` (the list Seatbelt grants from), symlinks resolved;
  • lies outside the read-only directories and `/dev`.

Those paths fill `violations` (at most five), so the observation says "Denied by the sandbox" and the activity draws its Sandbox row. The model can now tell that the sandbox caused the failure.

The nudge (`tools/grant_nudge.dart`, carried on `ToolResult.notes`)

  • It fires on a sandbox refusal, naming the denied path's folder, or on a second `unsandboxed` request for the same folder in one turn. The second case is unchanged: the folder comes from the command's leading `cd`, the directory the shell was left in, or the one it stood in before.
  • Either folder is widened to the nearest directory above it holding a `.git`, stopping short of home.
  • A command that only failed (a test, a `grep` with no match, `false`) gets no note.
  • It names each folder once per turn.
  • It is left off where `add_directory` is not the turn's to call, where the user denied the banner, and where `add_directory` itself would refuse the folder. Both read `refuseFolder` from `lib/domain/work_folders.dart`, so they always agree.

Also: `ShellResult.copyWith` replaces the hand-written copy. The start directory is worked out once per command and passed to the gate and the run. `AgentTool.handed` is the predicate the tool list is built from, and `grantsDirectories` reads it. `repositoryRootOf` is the `.git` walk shared by the instruction files, a worktree's git directory and the note.

Folders granted up front by project setting are another PR. The `lib/data/builtin/AGENTS.md` sandbox and Approvals sections are updated.

Tests

  • `permission_gate_test.dart`: a refused write yields `violations` and a note naming the repository root. A `grep` with no match, `false`, and a failing `flutter test` in an outside folder yield neither. The second unsandboxed request gets the note and the first does not. No note where `add_directory` is not offered.
  • `sandbox_denials_test.dart`: the error shapes of BSD and GNU tools, the shell, git, Python and Dart. Relative paths and `~`. Granted paths, read-only paths and program names are not counted.
  • `local_shell_test.dart` runs the platform's real sandbox through `LocalShell`: a refused `touch` comes back in `violations`, and a `grep` with no match comes back with none.
  • `seatbelt_test.dart` (macOS) and `landlock_test.dart` (Linux) run the real sandbox: a refused `touch` is found, and a plain failure is not.

…x command by command

The unsandboxed argument's description sent every write outside the
workspaces out of the sandbox and never named add_directory, whose own
description fired only on a folder the user named. Both now say a folder
the work keeps writing in is add_directory once, and a bash result names
the folder and the grant on a second unsandboxed request there in one turn
or a sandboxed run there that failed, read off the call's cd and the
shell's directory, never its output.
…failure

Neither Seatbelt nor Landlock reports a denial, so a confined run's output
is read for the kernel's own errno (EPERM, EACCES) naming a path outside
every write the sandbox granted. Those paths fill the result's violations,
so the model is told the failure was the sandbox's, and they are what the
add_directory note names. A command that only failed in an outside folder
gets neither.
…e reads it

Denials are read where a confined run ends, in LocalShell, so every run
under a sandbox reports them, from CommandSandbox.writes, the one list the
helpers grant from, and only once the output holds an errno. ShellResult
gains a general copyWith, the command's start directory is worked out
once, AgentTool.handed is the tool list's predicate, and repositoryRootOf
is the .git walk the instructions, worktree grants and the note share.
Merge master (#512, #515, #516) and read the nudge's grantability from
refuseFolder, the work-folders check add_directory answers with.
Merge master (#514): the gate's confinement memo carries where the command
starts. The note now skips a call the user denied on the banner, not one a
classifier or a rule refused, which a grant is still the way on from.
@Yiannis128
Yiannis128 merged commit df3461d into master Nov 10, 2026
2 checks passed
@Yiannis128
Yiannis128 deleted the feat/add-directory-nudge branch November 10, 2026 16:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant