Skip to content

Make confine run gh for the command, each time you allow it - #5

Draft
penberg wants to merge 2 commits into
mainfrom
confine-gh-broker
Draft

penberg wants to merge 2 commits into
mainfrom
confine-gh-broker

Conversation

@penberg

@penberg penberg commented Oct 29, 2026 •

Copy link
Copy Markdown
Owner

A confined command cannot run gh: it cannot read ~/.config/gh, so
gh fails before it does anything, and an agent in confine cannot open
a pull request.

Now, on macOS, if gh is installed, the command finds a shim first on its
PATH in place of gh: confine itself, under that name, which sends its
arguments, working directory and standard streams over a socket to
confine, outside the sandbox. confine shows a dialog with the command
line, and runs the real gh with the command's streams only if you allow
it: once, or always for that command, such as gh pr create, which is
kept in ~/.config/confine/allow. The dialog denies by itself after 10
seconds, so an agent no one is watching is not kept waiting. gh api is asked about every time, and gh auth, config,
alias, extension, secret, variable, ssh-key and gpg-key are
never run. CONFINE_ASK names a program to ask with instead of a dialog.
--headless turns the broker off, for when no one is at the screen.

gh runs git, which the repository's configuration and hooks can tell to
run any program, and the command can change both. So gh runs with a
clone of .git made where the command cannot write, with hooks and the
file system monitor off, and only if its configuration differs from what
it was when confine started in nothing but what gh and git push
write. The clone then replaces .git, so what gh changed is kept.

With the broker, confine waits for the command rather than becoming it.

gh's token is in the keychain, which the command can read, so the
broker keeps the command from running gh, but not from reading its
token.

Tools live in cmd/confine/tools/, one file each (gh.rs, and git.rs for the .git protection any tool that runs git needs).

Not done yet

  • The dialog has been seen to deny by itself, but its buttons have not been clicked by hand: the tests answer with CONFINE_ASK.
  • The token in the keychain can still be read by the command (security find-generic-password -s gh:github.com -w), so README.md and confine.1 saying the keychain guards its items is wrong for gh.
  • What the command changes in .git while gh runs is lost, gh goes on if the shim is killed, .git files of worktrees are not supported, and the repository must be on the same volume as the home directory.

A confined command cannot run `gh`: it cannot read `~/.config/gh`, so
`gh` fails before it does anything, and an agent in `confine` cannot open
a pull request.

Now, on macOS, if `gh` is installed, the command finds a shim first on its
`PATH` in place of `gh`: `confine` itself, under that name, which sends its
arguments, working directory and standard streams over a socket to
`confine`, outside the sandbox. `confine` shows a dialog with the command
line, and runs the real `gh` with the command's streams only if you allow
it: once, or always, until it exits, for that command, such as `gh pr
create`. `gh api` is asked about every time, and `gh auth`, `config`,
`alias`, `extension`, `secret`, `variable`, `ssh-key` and `gpg-key` are
never run. `CONFINE_ASK` names a program to ask with instead of a dialog.
`--headless` turns the broker off, for when no one is at the screen.

`gh` runs git, which the repository's configuration and hooks can tell to
run any program, and the command can change both. So `gh` runs with a
clone of `.git` made where the command cannot write, with hooks and the
file system monitor off, and only if its configuration differs from what
it was when `confine` started in nothing but what `gh` and `git push`
write. The clone then replaces `.git`, so what `gh` changed is kept.

With the broker, `confine` waits for the command rather than becoming it.

`gh`'s token is in the keychain, which the command can read, so the
broker keeps the command from running `gh`, but not from reading its
token.
The dialog waited 5 minutes for an answer, so an agent no one was
watching stopped for as long, and what you allowed always was forgotten
when `confine` exited.

Now Deny, the default button, counts down from 10, and is pressed when it
reaches zero; the command is told it was not allowed. The dialog is an
alert in JavaScript for Automation, as AppleScript's `display dialog` can
give up but not count down. What you allow always, such as `gh pr
create`, is added to `~/.config/confine/allow`, which the command can
neither read nor write, and is not asked again.

What each tool may be asked to do, and how it is kept from running what
the command wrote, moves out of the broker to `tools/`, one file each, so
that tools can be added: `tools/gh.rs` has `gh`'s, and `tools/git.rs` the
clone of `.git` that `gh`, and any tool that runs git, runs with.

"Broker" stays in the code, where it is the usual word, and leaves the
man page, which says what you see: `confine` asking to run a tool outside
the sandbox. The shim's socket is in `CONFINE_SOCKET`.
penberg added a commit that referenced this pull request Nov 1, 2026
A fork was everything in the directory, so a large `node_modules` or
`target` made forking slow. Now a fork and its base leave out what
`.gitignore` files ignore and git does not track. `--no-ignore`, for
`fsfork` and `confine`, forks everything as before. The name is the one
`rg` and `fd` use.

Measured on APFS with a debug build:

| Directory | `--no-ignore` | Default |
| --- | --- | --- |
| 500 source files, 200,000 ignored in `node_modules` | 4.7–7.1 s |
0.10–0.13 s |
| This repository (244 MB `target`, about 6,000 files) | 0.29–0.48 s |
0.09–0.17 s |

Asking git adds a fixed cost, so a directory with very few files forks
more slowly than before: about 0.15 s for a one-file directory.

## Behaviour changes

- **Cold builds:** builds in a fork no longer start warm unless
`--no-ignore` is given.
- **Ignored files are missing:** this includes files such as `.env`,
which a command in the fork may need.

## How it works

- **What counts as ignored:** only `.gitignore` files, as in `fsdiff`,
not `.git/info/exclude` or a global excludes file. Tracked files stay
even if a pattern matches them.
- **Subdirectories:** a subdirectory of a repository also goes by the
`.gitignore` files above it. A directory that is itself ignored goes by
its own only.
- **If git can't answer:** if git is missing or fails, for example on a
corrupt index, `fsfork` warns on standard error and forks everything.
- **Unchanged parts:** directories with nothing ignored in them are
still cloned whole, and the fork is still cloned from the base.

## Security

`confine` forks outside the sandbox, and the directory may be a fork
that a confined command changed, so git must not use its configuration:
`core.fsmonitor` alone would run a command. Git runs on a temporary
repository with no configuration and reads only `.gitignore` files and
the index. There is a test that a configured `core.fsmonitor` does not
run.

## Limitations

- A directory with something ignored in it is made anew, keeping its
permissions and modification time but not its other attributes.
- On macOS, `clonefile` rejects a FIFO or socket on its own, though it
takes them inside a directory. Next to something ignored, FIFOs are
remade, and sockets and devices are left out.
- This branches from `main`, and will touch the same lines of
`confine.rs` and `confine.1` as #5, so whichever lands second needs a
small rebase.

## Testing

`cargo test`, `cargo clippy` and `cargo fmt` pass on macOS, and I
checked by hand that repositories with a split or sparse index fork
correctly. CI runs the same tests on Linux, where the path through `cp`
is exercised; speed there is unmeasured.
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