Repository navigation
feat(bwrap): N2C token checks for service accounts, and a stricter session-directory guard - #35
Conversation
When the credentials directory is owned by root, accept it as system-managed tokens kept up to date by a root service, for an account that has no n2c-token session. Every directory from / down must be owned by root and not writable by group or others, and each token must be a regular, root-owned file that others cannot write and the account can read. An empty token is mounted, because the service rewrites it in place, and --n2c-token all skips token directories the account cannot enter. A directory owned by the current user keeps the existing 0700 checks. Documented in docs/bw-wrappers.md. Assisted-by: opencode:claude-sonnet-5-5
There was a problem hiding this comment.
🟡 Changes recommended
A critical path-validation flaw lets an attacker replace an unchecked symlink alias after validation and redirect a mount to a token file that never passed ownership checks.
1 open issue
What changed in this PR
Adds support for root-managed N2C tokens so service accounts can use the sandbox launchers without an n2c-token session.
Changes:
- Selects user or system credentials checks based on directory ownership.
- Adds system-token handling for permissions, empty files, inaccessible directories, and warnings.
- Documents service-account setup and in-place token refresh requirements.
| File | Description |
|---|---|
lib/bwrap_sandbox_lib.sh |
Adds system-managed token validation and mount handling. |
docs/bw-wrappers.md |
Documents the system layout, required permissions, and token update behavior. |
Review effort: Balanced
The ancestors of the system-managed credentials directory were checked after resolving it, so a symlink or '..' in the path as given was never examined, and the mount resolves the path again later. Require an absolute path with no symlink, '.', '..' or doubled slash in it, then check every directory above it as before. Assisted-by: opencode:claude-sonnet-5-5
There was a problem hiding this comment.
🔵 Needs a closer look
Reported tests mock root ownership, leaving service-account permissions, sandbox token readability, and in-place refresh visibility unverified in a real managed-host bwrap launch.
No open issues 🎉
1 resolved since last review
Review effort: Balanced
Replace the two credential layouts with one set of checks that applies whether you or root own the credentials directory. N2SN_CREDS_DIR must be an absolute path with no symlink in it. The credentials directory, each token directory and each token must be owned by you or by root, not writable by their group, and have no permission for others (so 0750, 0700, 0640, 0600 and 0440 pass, and 0755 and 0644 do not). A token directory must be enterable and a token readable. An empty token is mounted with a warning for both owners, and --n2c-token all skips token directories that cannot be entered. The parent directories are no longer walked. Assisted-by: opencode:claude-sonnet-5-5
…ne owner Walk the directories above the credentials directory up to /, and refuse the launch unless each is owned by you or by root and not writable by its group or others, so a parent cannot be renamed and replaced with an unchecked token before the mount resolves the path. Require each token directory and token to have the same owner as the credentials directory, so a tree is yours throughout or root's throughout. Assisted-by: opencode:claude-sonnet-5-5
There was a problem hiding this comment.
🔵 Needs a closer look
Human assessment is needed to weigh an unpublished documentation caveat that setting N2SN_CREDS_DIR does not grant service accounts access through the account checker or agent-install ACLs.
No open issues 🎉
2 resolved since last review
Review effort: Balanced
The check for mounts that expose the N2C session directory compared the source as written, so a symlinked source that leads to a directory holding it was missed. Compare the source as written and resolved against the session directory as written and resolved, in both directions (contains or lies inside). Assisted-by: opencode:claude-sonnet-5-5 (cherry picked from commit 47f1865)
…lugin A service account that sends requests to an AIFAPIM agents product needs an Entra ID token as well as its key. The token is mounted with --n2c-token aifapim like any other, and the aifapim-entra OpenCode 2 plugin reads the file named by AIFAPIM_TOKEN_FILE on every request. The sandbox environment is cleared, so the variable has to be passed through. bwopencode does not forward it: OpenCode 1 does not load the plugin. Assisted-by: opencode:claude-sonnet-5-5
There was a problem hiding this comment.
🔵 Needs a closer look
Mocked ownership tests leave the security-sensitive credential flow’s real root-owned token access, sandbox launch, in-place refresh, and deployed account-eligibility policy unverified on a representative managed host.
No open issues 🎉
Review effort: Balanced


Summary
Two N2C changes to the sandbox launcher, in one PR because both are about how N2C tokens reach the sandbox (this also contains what was #34):
n2c-tokensession, with one set of checks for tokens owned by you or by root.n2c-tokensession directory (the refresh token) out of the sandbox catch symlinked and nested mount sources.Description
Part 1: tokens for service accounts
--n2c-tokenonly accepted the layoutn2c-tokenmakes (a credentials directory owned by you, mode0700). A service account (for example the account that runs a beamline's coding agent) has non2c-tokensession: a root service on the host (a timer that gets app-only tokens with a certificate) writes the samecreds/NAME/tokenfiles and the account only reads them. Those root-owned directories failed the check, so the launch stopped.The launcher now refuses tokens that someone else could read or replace, the way
sshrefuses a private key. The same checks apply whether you or root own the credentials directory. Each stops the launch:N2SN_CREDS_DIRis an absolute path with no symlink,.,..or doubled/in it (a symlink could be changed between the check and the mount, which resolves the path again)./, is owned by you or by root and not writable by its group or others (otherwise a parent could be renamed and replaced with an unchecked token before the mount resolves the path). A directory others can write to, such as/tmp, is refused.n2c-token) or root's throughout (a service account's tokens).0750,0700,0640,0600and0440pass;0755,0775and0644do not. Group read (and enter, for a directory) is allowed: that is how a service account reads tokens that root writes.Other changes:
--n2c-token allskips token directories you cannot enter (another service's tokens in the same directory); naming one you cannot enter is an error._n2c_owner_modeand_n2c_my_uidare small functions so the checks can be tested without root-owned files.docs/bw-wrappers.mdhas a "Checks on the credentials" section and a "Service accounts" section with what a service must do when it writes tokens (root:GROUP, directories0750, files0440, rewritten in place).Behaviour change for existing
n2c-tokenusers: a creds directory that others can read (for example0755) is refused as before, but a group-readable0750one now passes.n2c-tokenitself creates0700and0600, so nothing changes in practice.Part 2: session-directory guard (was #34, follow-up to #32)
verify_n2c_state_not_exposedruns after every mount is built and stops the launch if a bind mount would expose the N2C session directory (it holds the MSAL cache with the refresh token). It had two gaps:~/.npmlinked to a directory that containsN2C_STATE_DIR.Now each source is compared as written and with symlinks resolved (
readlink -f) against the session directory as written and resolved, in both directions: the source is the session directory, contains it or lies inside it. The error message is unchanged.docs/bw-wrappers.mddescribes the wider check with the symlinked~/.npmexample. This is commit47f1865from #34, unchanged.The two parts do not interact: a service account has no session directory, and the guard does nothing while that directory does not exist.
Testing
pixi run -e dev lint-files lib/bwrap_sandbox_lib.sh docs/bw-wrappers.md: all hooks pass (shfmt, shellcheck, codespell, markdownlint, gitleaks, mypy).0700and0600mount and setN2SN_CREDS_DIR;0755and a0775creds directory, and0644and0660tokens are refused;0750and0640pass; an empty token is mounted with then2c-tokenhint; a symlinked creds directory is refused.0750and0440mount, andallfinds all three;0755creds directory, a group-writable token directory, a world-readable token, a token or directory owned by a third user, an unreadable token and a symlinked token are refused; an empty token is mounted with the root-service hint;allskips a directory it cannot enter; a missing token file only warns.1777(/tmp-like) parent and a parent owned by a third user are refused; a0755root-owned and a0700own parent pass. These fail on the earlier version...and a relative path are refused, and the same tree by its real path mounts. These fail on the earlier version.validate_extra_pathaccepts a token path from a root-owned tree.mainthe 4 gap cases fail, as expected: the state directory itself, a directory that contains it, a directory inside it, a symlink to a directory that contains it (~/.npmlinked), a symlink to the state directory, a symlink to a directory inside it, the state given through a symlink with the real parent as source (all refused), and an unrelated directory and a sibling that only shares a prefix (allowed).N2SN_CREDS_DIR=/run/nsls2/agent/creds bwopencode2 --n2c-token all --dry-runon a beamline workstation, then a real launch with one skill call, and a token refresh seen from inside the sandbox.Checklist
pixi run -e dev lintlocally (or wait for CI) — see.github/workflows/lint.ymlrunansibledeployment)