Skip to content

🛡️ fix: Deny a Non-Lane Root's Own Git Metadata in the Native SRT Sandbox - #276

Merged
danny-avila merged 2 commits into
mainfrom
danny-avila/root-git-metadata-denies
Sep 30, 2026
Merged

danny-avila merged 2 commits into
mainfrom
danny-avila/root-git-metadata-denies

Conversation

@danny-avila

Copy link
Copy Markdown
Collaborator

What breaks

In a native (non-lane) SRT workspace on Linux, a trusted-vm command could
write the registered root's own .git/hooks/* and .git/config. That
lets a sandboxed command plant a hook, or a core.fsmonitor / filter.* /
diff.* config entry, that then runs unsandboxed the next time the user — or
any trusted worker code — invokes Git in that checkout. Verified live against a
trusted-vm sandbox: from a worker cwd outside the workspace, both
printf … > .git/hooks/post-checkout and appending [core] fsmonitor=… to
.git/config succeeded on the host.

What triggers it

The non-lane config relied on SRT's mandatory .git/hooks and .git/config
denies. On Linux those are computed from the worker process's current
directory (node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/linux-sandbox-utils.js,
const cwd = process.cwd()), and the per-call cwd passed to
wrapWithSandboxArgv is documented as unused there. The worker's cwd is its home
(librechat-code@.service sets no WorkingDirectory), never the registered
root, so the mandatory denies landed elsewhere and the root's .git stayed
writable. macOS was unaffected: it applies global **/.git/hooks/** and
**/.git/config Seatbelt patterns.

Behavior after the change

NativeSrtWorkspaceCommandSandbox now adds explicit denyWrite entries for the
registered root's own Git metadata when <root>/.git is a directory. Lane
workspaces are untouched — they already keep the entire common Git directory
read-only. The denies cover:

  • <root>/.git/hooks and <root>/.git/config — unconditionally;
  • <root>/.git/config.worktree and <root>/.git/commondir — where they exist;
  • the same files for every submodule Git directory under .git/modules/**
    (including recursively nested submodules) and every .git/worktrees/*.

commondir and config.worktree are denied only where present, because Git
reads them strictly (commondir at every startup; config.worktree whenever the
worktreeConfig extension is on) and SRT masks a non-existent deny target with
an empty /dev/null bind that Git then fails to parse — which would break
ordinary commands. Existing denied files are re-bound read-only, so .git/config
stays readable (remotes keep working) while writes fail; a benign
git commit, git worktree add, git gc, etc. in the workspace are unaffected.
.git/info is intentionally not denied: its attributes only reference
filter/diff drivers by name, and those drivers' commands live in the already
read-only config.

Residual, documented limitation

Because the workspace itself stays writable, a command can still stage config
Git will honor later by restructuring the repo — replacing the whole .git
directory, writing a fresh top-level .git/commondir that redirects the common
directory, or initializing a nested repository. Closing those would require
making the workspace's Git storage structurally read-only, which the
personal-machine SRT trust model does not (and which macOS's patterns also do not
cover). packages/code/README.md documents this; use the Docker/NsJail backend
or a dedicated VM when a workspace command must be treated as adversarial.

Mechanism

initializeOnce()
  root = realpath(workspaceRoot)
  rootGitMetadataDenies = lane || <root>/.git not a dir
      ? []
      : collectGitMetadataDenies(<root>/.git)   // hooks, config, +if-exists worktree/commondir,
                                                 //   submodules (recursive), linked worktrees
  config.filesystem.denyWrite = [...protected, ...inherited, ...rootGitMetadataDenies, ...guard]
  this.denyWritePaths        = [...protected, ...inherited, ...rootGitMetadataDenies, ...guard]

On Linux, an existing deny becomes SRT's --ro-bind <file> <file> (readable,
EROFS on write); a non-existent one becomes --ro-bind /dev/null <leaf>
(blocks creation) — the reason the strictly-read commondir/config.worktree
are gated on existence.

Tests

  • packages/code/src/root-git-denies-live.test.ts (new, gated on
    LIBRECHAT_CODE_LIVE_SRT_TESTS=1): runs the real sandbox with process.cwd()
    set outside the root and asserts hooks, config, an existing per-worktree
    config, a submodule config, and a linked-worktree commondir cannot be tampered
    — and that planting a fresh .git/hooks/pre-commit is blocked — while a normal
    commit still succeeds and no planted marker runs on the host. Wired into the
    linux-native-sandbox-tests CI job.
  • packages/code/src/native-sandbox.test.ts (new case): asserts the
    computed denyWrite set for a non-lane root, including recursive submodule and
    linked-worktree metadata, and that an absent top-level commondir /
    submodule config.worktree is left alone.
  • npm test (Node 20/22/24) and npx tsc --noEmit pass in packages/code.

Related to the linked-worktree lane work (#270, #272, #274), which already
handled the lane variant of this surface.

…dbox

A trusted-vm command in a native (non-lane) SRT workspace could write the
registered root's own `.git/hooks/*` and `.git/config`, planting a hook or a
`core.fsmonitor` / `filter.*` entry that then runs unsandboxed the next time
the user — or trusted worker code — invokes Git in that checkout.

The non-lane config relied on SRT's mandatory `.git/hooks` and `.git/config`
denies, which on Linux are computed from the worker *process's* current
directory (see `sandbox/linux-sandbox-utils.js`). The worker's cwd is its home
(the systemd user unit sets no WorkingDirectory), never the registered root, so
those denies landed elsewhere and the root's `.git` was writable. macOS was
unaffected because it applies global `**/.git/hooks/**` and `**/.git/config`
Seatbelt patterns.

`NativeSrtWorkspaceCommandSandbox` now adds explicit `denyWrite` entries for the
registered root's own Git metadata when `<root>/.git` is a directory (lanes are
untouched — they already keep the whole common Git directory read-only):

- `<root>/.git/hooks` and `<root>/.git/config` unconditionally;
- `<root>/.git/config.worktree` and `<root>/.git/commondir` where they exist;
- the same files for every submodule Git directory under `.git/modules/**`
  (including nested submodules) and every `.git/worktrees/*`.

`commondir` and `config.worktree` are denied only where present: Git reads them
strictly and SRT would otherwise mask an absent target with an empty `/dev/null`
bind Git cannot parse, breaking ordinary commands. Denied files that exist are
re-bound read-only, so `.git/config` stays readable (remotes keep working) while
writes fail; a benign `git commit` in the workspace is unaffected.

Tests: a live SRT test gated on `LIBRECHAT_CODE_LIVE_SRT_TESTS=1` runs the
sandbox with `process.cwd()` outside the root and asserts hooks, config, an
existing per-worktree config, a submodule config, and a linked-worktree
commondir cannot be tampered while a normal commit still succeeds; wired into
the Linux native-sandbox CI job. A fast unit test asserts the computed
`denyWrite` set. `packages/code/README.md` documents the guarantee and the
residual writable-`.git` vectors (whole-`.git` replacement, a fresh top-level
commondir, a nested repo) that the personal-machine SRT trust model accepts.
@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review

Head: b3df2c8

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-30T12:00:51.755680Z cdfcce0 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b3df2c8771

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/code/src/native-sandbox.ts Outdated
*/
async function collectGitMetadataDenies(gitDir: string): Promise<string[]> {
const denies = [join(gitDir, 'hooks'), join(gitDir, 'config')];
await pushExistingDeny(denies, join(gitDir, 'config.worktree'));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Deny absent worktree configs when the extension is enabled

When .git/config already contains extensions.worktreeConfig=true but .git/config.worktree does not yet exist, this existence check omits the path from denyWrite. A sandboxed command can then run git config --worktree core.fsmonitor <command>—git config -h defines --worktree as using the per-worktree config file—which creates the omitted file, and the next unsandboxed Git operation executes that command. This occurs in the live test's own repository setup, where the extension is enabled for a linked worktree while the main worktree has no per-worktree config; an empty /dev/null-backed config is valid to Git, so config.worktree should be denied even when absent.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not taking the proposed fix: denying an absent config.worktree breaks Git in exactly the repositories this targets. Corrected the misleading comment and documented the case in cdfcce0.

Evidence (Linux, real SRT 0.0.75, bwrap): a repo with extensions.worktreeConfig=true and no main-worktree config.worktree, git status inside the sandbox:

  • current code → exit=0
  • config.worktree denied even when absent → exit=128, warning: unable to access '.git/config.worktree': Permission denied … fatal: unknown error occurred while reading the configuration files

SRT blocks creation of a missing path with a /dev/null stub that Git cannot open, and Git treats that as fatal for a config file it is required to read. So "an empty /dev/null-backed config is valid to Git" does not hold here. The same constraint applies to commondir, which Git reads at every startup.

Why this is documented rather than closed: a missing file that Git reads strictly can't be creation-blocked without breaking Git. A new top-level commondir is in the same class, doesn't depend on the extension, and redirects the common directory just as effectively. Closing only config.worktree would not change what a sandboxed command can do. packages/code/README.md now lists "creating a missing config.worktree in a repository that already enables worktreeConfig" next to the replaced .git, new commondir, and nested-repo residuals. The guarantee this PR makes is that existing executable Git metadata can't be modified, which covers the hooks/config vector from the issue and matches macOS. macOS's Seatbelt patterns don't cover config.worktree creation either.

The code comment you anchored on was wrong to imply the extension could only be enabled through the denied .git/config; it now states the residual explicitly.

Also in cdfcce0, from self-review: the deny set is recomputed for every root command, since the worker outlives its initialization snapshot. Metadata the worker cannot inspect now fails the command closed instead of dropping a deny. Both new tests were checked by mutation: each fails with its behavior removed.

The root's Git metadata denies were a snapshot taken at sandbox
initialization, but the worker is long-lived: a repository, submodule, or
linked worktree created on the host afterwards stayed writable until restart.
Ordinary root commands now recompute the set and pass it as a per-command
`denyWrite` override (every other filesystem field is the session policy),
the way SRT recomputes its own mandatory denies on every wrap. Native Windows
keeps the initialization snapshot because srt-win rejects per-command
allowRead/allowWrite.

Inspecting the metadata now treats only ENOENT/ENOTDIR as absence; any other
failure propagates, so an unreadable `.git` fails the command closed
(COMMAND_UNAVAILABLE) instead of silently dropping a deny.

Corrects the `config.worktree` comment: in a repository that already enables
`worktreeConfig`, a missing per-worktree config stays creatable, because
denying an absent file makes every Git command fail. The README lists it with
the other writable-workspace residuals.
@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review

Head: cdfcce0

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep it up!

Reviewed commit: cdfcce085c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review — final review: flag only blocking issues.

Head: cdfcce0

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Already looking forward to the next diff.

Reviewed commit: cdfcce085c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@danny-avila
danny-avila merged commit 836d001 into main Sep 30, 2026
11 checks passed
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