From 458b4082a9e5a5fea135d8f5e1f3c50b20b867a2 Mon Sep 17 00:00:00 2001 From: "Stuart B. Wilkins" Date: Sat, 31 Oct 2026 20:56:47 -0400 Subject: [PATCH 1/6] feat(bwrap): mount system-managed N2C tokens for service accounts 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 --- docs/bw-wrappers.md | 50 +++++++++++-- lib/bwrap_sandbox_lib.sh | 150 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 190 insertions(+), 10 deletions(-) diff --git a/docs/bw-wrappers.md b/docs/bw-wrappers.md index e136490..0860783 100644 --- a/docs/bw-wrappers.md +++ b/docs/bw-wrappers.md @@ -129,10 +129,13 @@ NSLS-II API in `/run/user//nsls2/creds//token`, for example the launch stops with an error if any bind mount would contain the N2C session directory, for example with `N2C_STATE_DIR` set inside `~/.npm`. The default location, under `/run/user/`, is never inside such a directory. -- **The directories are checked.** The creds directory and each token directory - must be real directories (not symlinks), owned by you, with mode `0700`, and - a token must be a regular file, not a symlink; otherwise the launch stops with - an error. `n2c-token` creates them that way. +- **The directories are checked.** When the creds directory is owned by you + (the layout `n2c-token` makes), the creds directory and each token directory + must be real directories (not symlinks) with mode `0700`, and a token must be + a regular file, not a symlink; otherwise the launch stops with an error. A + creds directory owned by root is a service account's, see + [Service accounts](#service-accounts-system-managed-tokens) below. Any other + owner is an error. - **A missing or empty token only warns**; run `n2c-token login` on the host. A token that expires while the sandbox runs is refreshed in place by the `n2c-token` timer (`n2c-token install` on the host). @@ -145,6 +148,45 @@ NSLS-II API in `/run/user//nsls2/creds//token`, for example Example: `bwopencode2 --n2c-token observability --n2c-token epics`. +### Service accounts (system-managed tokens) + +A service account has no `n2c-token` session. A root service on the host (for +example a timer that gets app-only tokens with a certificate) writes the same +`creds/NAME/token` files, and the account only reads them. The launcher picks +this layout when **the creds directory is owned by root**. There is no flag: +point `N2SN_CREDS_DIR` at the directory and use `--n2c-token` as usual. + +```bash +N2SN_CREDS_DIR=/run/nsls2/agent/creds bwopencode2 --n2c-token all +``` + +Because nobody but root may change these files, the checks differ from the user +layout. The launch stops with an error unless all of these hold: + +- the creds directory, every directory above it up to `/`, and each token + directory are real directories (not symlinks), owned by root and not + writable by group or others (otherwise a parent could be renamed and replaced + by someone else's tree); +- you can enter the token directory, and each token is a regular file (not a + symlink), owned by root, not writable by group or others, and readable by + you. + +Differences from the user layout: + +- **An empty token is mounted**, with a warning. The service creates the file + at boot and rewrites it in place, so a mounted file shows the token as soon + as it is written. A token with no file yet is skipped with a warning. +- **`--n2c-token all` skips token directories you cannot enter** (another + service's tokens in the same directory), while naming one you cannot enter is + an error. +- **The hint is different**: there is no `n2c-token login` to run, so the + warnings say the tokens are kept by a root service on the host. + +A service that keeps tokens for this layout should create the directories as +`root:GROUP` with mode `0750` and the files as `root:GROUP` with mode `0440`, +where `GROUP` is the group of the account that runs the sandbox, and rewrite a +token in place (same file) rather than replace it. + ## Sandbox context An agent that does not know it runs in a sandbox guesses: it takes a read-only diff --git a/lib/bwrap_sandbox_lib.sh b/lib/bwrap_sandbox_lib.sh index 9931e4f..337a0f1 100644 --- a/lib/bwrap_sandbox_lib.sh +++ b/lib/bwrap_sandbox_lib.sh @@ -1622,6 +1622,104 @@ _n2c_require_private() { fi } +# System-managed credentials. A service account has no n2c-token session: a root +# service on the host (for example a timer that gets app-only tokens) keeps the +# same creds/NAME/token files up to date and the account only reads them. That +# layout is chosen when the credentials directory is owned by root, and is safe +# as long as nobody but root can change the directories or files on the way, so +# these checks replace "owned by you, mode 0700". + +# _n2c_owner_mode PATH +# Print "UID MODE" for PATH (MODE is the octal permission bits, for example +# "0 750"), or nothing when PATH cannot be read. A function of its own so +# that the checks below can be tested without root-owned files. +_n2c_owner_mode() { + stat -c '%u %a' -- "$1" 2> /dev/null || true +} + +# _n2c_my_uid +# Print the numeric user id (a function so tests can change it). +_n2c_my_uid() { + id -u +} + +# _n2c_root_owned_sealed PATH +# Return 0 when PATH is owned by root and neither its group nor others can +# write to it. +_n2c_root_owned_sealed() { + local om mode + om="$(_n2c_owner_mode "$1")" + [[ -n "${om}" && "${om%% *}" == "0" ]] || return 1 + mode="${om##* }" + [[ "${mode}" =~ ^[0-7]+$ ]] || return 1 + ((($((8#${mode})) & 8#022) == 0)) +} + +# _n2c_creds_layout DIR +# Print "user" when DIR is owned by the current user (n2c-token's own +# credentials) or "system" when it is owned by root (system-managed +# credentials). Return 1 with a message for a symlink, a missing +# directory or any other owner. +_n2c_creds_layout() { + local dir om uid + dir="$(_n2c_strip_slashes "$1")" + if [[ -L "${dir}" ]] || [[ ! -d "${dir}" ]]; then + echo "Error: N2C credentials directory '${dir}' must be a real directory (not a symlink). Refusing to mount N2C tokens." >&2 + return 1 + fi + om="$(_n2c_owner_mode "${dir}")" + uid="${om%% *}" + if [[ -n "${om}" && "${uid}" == "$(_n2c_my_uid)" ]]; then + echo "user" + elif [[ -n "${om}" && "${uid}" == "0" ]]; then + echo "system" + else + echo "Error: N2C credentials directory '${dir}' must be owned by you (n2c-token) or by root (system-managed tokens). Refusing to mount N2C tokens." >&2 + return 1 + fi +} + +# _n2c_require_system_dir DIR WHAT +# Exit 1 unless DIR is a real directory (not a symlink), owned by root, not +# writable by its group or others, and one the current user can enter. +_n2c_require_system_dir() { + local dir what="$2" + dir="$(_n2c_strip_slashes "$1")" + if [[ -L "${dir}" ]] || [[ ! -d "${dir}" ]]; then + echo "Error: ${what} '${dir}' must be a real directory (not a symlink). Refusing to mount N2C tokens." >&2 + exit 1 + fi + if ! _n2c_root_owned_sealed "${dir}"; then + echo "Error: ${what} '${dir}' must be owned by root and not writable by its group or others. Refusing to mount N2C tokens." >&2 + exit 1 + fi + if [[ ! -x "${dir}" ]]; then + echo "Error: ${what} '${dir}' cannot be entered by you (check its group). Refusing to mount N2C tokens." >&2 + exit 1 + fi +} + +# _n2c_require_sealed_ancestors DIR WHAT +# Exit 1 unless every directory above DIR (DIR resolved, up to "/") is owned +# by root and not writable by its group or others. Otherwise someone could +# rename a parent and put their own tree in its place. +_n2c_require_sealed_ancestors() { + local what="$2" p + p="$(readlink -f -- "$1" 2> /dev/null || true)" + if [[ -z "${p}" ]]; then + echo "Error: cannot resolve ${what} '$1'. Refusing to mount N2C tokens." >&2 + exit 1 + fi + while [[ "${p}" != "/" ]]; do + p="${p%/*}" + [[ -n "${p}" ]] || p="/" + if ! _n2c_root_owned_sealed "${p}"; then + echo "Error: '${p}' above ${what} '$1' must be owned by root and not writable by its group or others. Refusing to mount N2C tokens." >&2 + exit 1 + fi + done +} + # build_n2c_token_mounts # Add the token files requested with --n2c-token to EXTRA_RO_PATHS, so # build_extra_path_mounts validates and mounts each one read-only at its own @@ -1636,6 +1734,16 @@ _n2c_require_private() { # owned by someone else or is not mode 0700, or a token file that is a # symlink or not a regular file, is an error: the launch stops. # +# Two layouts, chosen by who owns the credentials directory: +# user owned by you: n2c-token's own files, directories mode 0700. +# system owned by root: tokens kept up to date by a root service, for a +# service account. 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 you can +# read. An empty token is mounted too (the service fills the file +# in place, so it shows up as soon as it is written), and `all` +# skips token directories you cannot enter. +# # Must be called BEFORE build_extra_path_mounts. build_n2c_token_mounts() { [[ -z "${N2C_TOKENS[*]+x}" ]] && return 0 @@ -1655,10 +1763,19 @@ build_n2c_token_mounts() { done if [[ ! -e "${root}" && ! -L "${root}" ]]; then - echo "Warning: N2C credentials directory '${root}' does not exist; no tokens mounted. Run: n2c-token login" >&2 + echo "Warning: N2C credentials directory '${root}' does not exist; no tokens mounted. Run: n2c-token login (or, for a service account, check that its token service has run)" >&2 return 0 fi - _n2c_require_private "${root}" "N2C credentials directory" + local layout hint + layout="$(_n2c_creds_layout "${root}")" || exit 1 + if [[ "${layout}" == "system" ]]; then + hint="These tokens are kept by a root service on this host; ask its administrator." + _n2c_require_system_dir "${root}" "N2C credentials directory" + _n2c_require_sealed_ancestors "${root}" "N2C credentials directory" + else + hint="Run: n2c-token login" + _n2c_require_private "${root}" "N2C credentials directory" + fi if [[ ${want_all} -eq 1 ]]; then names=() @@ -1667,6 +1784,7 @@ build_n2c_token_mounts() { tdir="${tdir%/}" name="${tdir##*/}" [[ "${name}" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]] || continue + [[ "${layout}" == "system" && ! -x "${tdir}" ]] && continue names+=("${name}") done fi @@ -1678,14 +1796,34 @@ build_n2c_token_mounts() { echo "Warning: no N2C token '${name}' (${tdir} does not exist); not mounted." >&2 continue fi - _n2c_require_private "${tdir}" "N2C token directory" + if [[ "${layout}" == "system" ]]; then + _n2c_require_system_dir "${tdir}" "N2C token directory" + else + _n2c_require_private "${tdir}" "N2C token directory" + fi if [[ -L "${tfile}" ]] || { [[ -e "${tfile}" ]] && [[ ! -f "${tfile}" ]]; }; then echo "Error: N2C token '${tfile}' must be a regular file, not a symlink. Refusing to mount N2C tokens." >&2 exit 1 fi - if [[ ! -s "${tfile}" ]]; then + if [[ "${layout}" == "system" ]]; then + if [[ ! -e "${tfile}" ]]; then + echo "Warning: N2C token '${name}' has no file yet (${tfile}); not mounted. ${hint}" >&2 + continue + fi + if ! _n2c_root_owned_sealed "${tfile}"; then + echo "Error: N2C token '${tfile}' must be owned by root and not writable by its group or others. Refusing to mount N2C tokens." >&2 + exit 1 + fi + if [[ ! -r "${tfile}" ]]; then + echo "Error: N2C token '${tfile}' cannot be read by you (check its group). Refusing to mount N2C tokens." >&2 + exit 1 + fi + if [[ ! -s "${tfile}" ]]; then + echo "Warning: N2C token '${name}' is empty for now; mounted, it shows up when written. ${hint}" >&2 + fi + elif [[ ! -s "${tfile}" ]]; then if [[ ${want_all} -eq 0 ]]; then - echo "Warning: N2C token '${name}' is missing or empty; not mounted. Run: n2c-token login" >&2 + echo "Warning: N2C token '${name}' is missing or empty; not mounted. ${hint}" >&2 fi continue fi @@ -1693,7 +1831,7 @@ build_n2c_token_mounts() { done if [[ ${want_all} -eq 1 && ${#found[@]} -eq 0 ]]; then - echo "Warning: no N2C tokens found in '${root}'; none mounted. Run: n2c-token login" >&2 + echo "Warning: no N2C tokens found in '${root}'; none mounted. ${hint}" >&2 fi for tfile in "${found[@]+"${found[@]}"}"; do EXTRA_RO_PATHS+=("${tfile}") From e5440bac2e83272ff208ab44dc47a06f6db9e7a5 Mon Sep 17 00:00:00 2001 From: "Stuart B. Wilkins" Date: Sun, 1 Nov 2026 07:45:25 -0500 Subject: [PATCH 2/6] fix(bwrap): check the path of system-managed tokens as written 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 --- docs/bw-wrappers.md | 10 +++++++--- lib/bwrap_sandbox_lib.sh | 22 +++++++++++++++------- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/docs/bw-wrappers.md b/docs/bw-wrappers.md index 0860783..eb253a8 100644 --- a/docs/bw-wrappers.md +++ b/docs/bw-wrappers.md @@ -163,10 +163,14 @@ N2SN_CREDS_DIR=/run/nsls2/agent/creds bwopencode2 --n2c-token all Because nobody but root may change these files, the checks differ from the user layout. The launch stops with an error unless all of these hold: +- the creds directory is given as an absolute path with no symlink in it (and + no `.`, `..` or doubled `/`), so use the real path, for example `/run/...` + and not `/var/run/...`: a symlink could be changed between the check and the + mount; - the creds directory, every directory above it up to `/`, and each token - directory are real directories (not symlinks), owned by root and not - writable by group or others (otherwise a parent could be renamed and replaced - by someone else's tree); + directory are real directories, owned by root and not writable by group or + others (otherwise a parent could be renamed and replaced by someone else's + tree); - you can enter the token directory, and each token is a regular file (not a symlink), owned by root, not writable by group or others, and readable by you. diff --git a/lib/bwrap_sandbox_lib.sh b/lib/bwrap_sandbox_lib.sh index 337a0f1..618fcc9 100644 --- a/lib/bwrap_sandbox_lib.sh +++ b/lib/bwrap_sandbox_lib.sh @@ -1700,14 +1700,22 @@ _n2c_require_system_dir() { } # _n2c_require_sealed_ancestors DIR WHAT -# Exit 1 unless every directory above DIR (DIR resolved, up to "/") is owned -# by root and not writable by its group or others. Otherwise someone could -# rename a parent and put their own tree in its place. +# Exit 1 unless DIR is an absolute path with no symlink (and no "." or ".." +# or doubled slash) in it, and every directory above it, up to "/", is owned +# by root and not writable by its group or others. The path is checked as +# written, not only after resolving it: a symlink anywhere in the path could +# be changed between this check and the mount, which resolves the path again, +# and a writable parent could be renamed and replaced by someone else's tree. _n2c_require_sealed_ancestors() { - local what="$2" p - p="$(readlink -f -- "$1" 2> /dev/null || true)" - if [[ -z "${p}" ]]; then - echo "Error: cannot resolve ${what} '$1'. Refusing to mount N2C tokens." >&2 + local what="$2" p canon + p="$(_n2c_strip_slashes "$1")" + if [[ "${p}" != /* ]]; then + echo "Error: ${what} '${p}' must be an absolute path. Refusing to mount N2C tokens." >&2 + exit 1 + fi + canon="$(readlink -f -- "${p}" 2> /dev/null || true)" + if [[ "${canon}" != "${p}" ]]; then + echo "Error: ${what} '${p}' goes through a symlink or has a '.', '..' or doubled '/' in it (it resolves to '${canon}'). Give the real path. Refusing to mount N2C tokens." >&2 exit 1 fi while [[ "${p}" != "/" ]]; do From a35b3edabd51b5c0a8f5ae12a94e2c8931993587 Mon Sep 17 00:00:00 2001 From: "Stuart B. Wilkins" Date: Sun, 1 Nov 2026 08:14:09 -0500 Subject: [PATCH 3/6] refactor(bwrap): check N2C tokens like an ssh private key 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 --- docs/bw-wrappers.md | 88 +++++++-------- lib/bwrap_sandbox_lib.sh | 223 ++++++++++++++------------------------- 2 files changed, 123 insertions(+), 188 deletions(-) diff --git a/docs/bw-wrappers.md b/docs/bw-wrappers.md index eb253a8..471d20c 100644 --- a/docs/bw-wrappers.md +++ b/docs/bw-wrappers.md @@ -115,8 +115,8 @@ NSLS-II API in `/run/user//nsls2/creds//token`, for example - **Nothing is mounted by default.** `--n2c-token NAME` mounts `creds/NAME/token` read-only at the same path; repeat it for several tokens. - `--n2c-token all` mounts every non-empty token in the directory. `all` - together with names means all. + `--n2c-token all` mounts every token in the directory that you can enter. + `all` together with names means all. - **Only token files are mounted**, one file each. `n2c-token` rewrites a token in place, so a mounted file sees every refresh while the timer runs. - **The refresh token is never mounted.** The MSAL cache (`cache.json`) lives @@ -129,16 +129,13 @@ NSLS-II API in `/run/user//nsls2/creds//token`, for example the launch stops with an error if any bind mount would contain the N2C session directory, for example with `N2C_STATE_DIR` set inside `~/.npm`. The default location, under `/run/user/`, is never inside such a directory. -- **The directories are checked.** When the creds directory is owned by you - (the layout `n2c-token` makes), the creds directory and each token directory - must be real directories (not symlinks) with mode `0700`, and a token must be - a regular file, not a symlink; otherwise the launch stops with an error. A - creds directory owned by root is a service account's, see - [Service accounts](#service-accounts-system-managed-tokens) below. Any other - owner is an error. -- **A missing or empty token only warns**; run `n2c-token login` on the host. A - token that expires while the sandbox runs is refreshed in place by the - `n2c-token` timer (`n2c-token install` on the host). +- **The tokens are checked like an ssh private key** (see + [Checks](#checks-on-the-credentials) below): a directory or token that + someone else could read or replace stops the launch. +- **A missing token only warns**; run `n2c-token login` on the host. An empty + token is mounted, with a warning: it is filled in place, so a mounted file + shows the token as soon as it is written and every refresh after that, + while the `n2c-token` timer runs (`n2c-token install` on the host). - **Locations.** `N2SN_CREDS_DIR` and `N2C_STATE_DIR` override the two directories, as they do for `n2c-token`. - **The sandbox is told where the tokens are.** The sandbox environment is @@ -148,48 +145,45 @@ NSLS-II API in `/run/user//nsls2/creds//token`, for example Example: `bwopencode2 --n2c-token observability --n2c-token epics`. -### Service accounts (system-managed tokens) +### Checks on the credentials + +The launcher refuses to mount tokens that someone else could read or replace, +the way `ssh` refuses a private key that others can read. Each of these stops +the launch with an error: + +- `N2SN_CREDS_DIR` is an absolute path with no symlink, `.`, `..` or doubled + `/` in it, so use the real path (`/run/...`, not `/var/run/...`): a symlink + could be changed between the check and the mount. +- The creds directory, each token directory and each token are **owned by you + or by root**, are **not writable by their group**, and have **no permission + at all for others** (mode `0750`, `0700`, `0640`, `0600` or `0440` pass; + `0755`, `0775` or `0644` do not). Group read, and group enter for a + directory, are allowed. +- The directories are real directories, you can enter a token directory and + read its token, and a token is a regular file, not a symlink. + +Directories above the creds directory are not checked: keep `N2SN_CREDS_DIR` +somewhere nobody else can write, such as `/run/user//...` or `/run/...`. +`--n2c-token all` skips token directories you cannot enter. + +### Service accounts A service account has no `n2c-token` session. A root service on the host (for -example a timer that gets app-only tokens with a certificate) writes the same -`creds/NAME/token` files, and the account only reads them. The launcher picks -this layout when **the creds directory is owned by root**. There is no flag: -point `N2SN_CREDS_DIR` at the directory and use `--n2c-token` as usual. +example a timer that gets app-only tokens with a certificate) keeps the same +`creds/NAME/token` files up to date, and the account only reads them. Nothing +extra is needed: the creds directory is owned by root, and the launcher uses +the same checks. ```bash N2SN_CREDS_DIR=/run/nsls2/agent/creds bwopencode2 --n2c-token all ``` -Because nobody but root may change these files, the checks differ from the user -layout. The launch stops with an error unless all of these hold: - -- the creds directory is given as an absolute path with no symlink in it (and - no `.`, `..` or doubled `/`), so use the real path, for example `/run/...` - and not `/var/run/...`: a symlink could be changed between the check and the - mount; -- the creds directory, every directory above it up to `/`, and each token - directory are real directories, owned by root and not writable by group or - others (otherwise a parent could be renamed and replaced by someone else's - tree); -- you can enter the token directory, and each token is a regular file (not a - symlink), owned by root, not writable by group or others, and readable by - you. - -Differences from the user layout: - -- **An empty token is mounted**, with a warning. The service creates the file - at boot and rewrites it in place, so a mounted file shows the token as soon - as it is written. A token with no file yet is skipped with a warning. -- **`--n2c-token all` skips token directories you cannot enter** (another - service's tokens in the same directory), while naming one you cannot enter is - an error. -- **The hint is different**: there is no `n2c-token login` to run, so the - warnings say the tokens are kept by a root service on the host. - -A service that keeps tokens for this layout should create the directories as -`root:GROUP` with mode `0750` and the files as `root:GROUP` with mode `0440`, -where `GROUP` is the group of the account that runs the sandbox, and rewrite a -token in place (same file) rather than replace it. +The warnings then say the tokens are kept by a root service instead of "Run: +n2c-token login". A service that keeps tokens this way should create the +creds directory and each token directory as `root:GROUP` with mode `0750`, and +each token as `root:GROUP` with mode `0440`, where `GROUP` is the group of the +account that runs the sandbox, and rewrite a token in place (the same file) +rather than replace it, because the sandbox mounts the file itself. ## Sandbox context diff --git a/lib/bwrap_sandbox_lib.sh b/lib/bwrap_sandbox_lib.sh index 618fcc9..3a4706a 100644 --- a/lib/bwrap_sandbox_lib.sh +++ b/lib/bwrap_sandbox_lib.sh @@ -1606,28 +1606,12 @@ add_n2c_token() { N2C_TOKENS+=("${name}") } -# _n2c_require_private DIR WHAT -# Exit 1 unless DIR is a real directory (not a symlink) owned by the -# current user with mode 0700. -_n2c_require_private() { - local dir what="$2" - dir="$(_n2c_strip_slashes "$1")" - if [[ -L "${dir}" ]] || [[ ! -d "${dir}" ]] || [[ ! -O "${dir}" ]]; then - echo "Error: ${what} '${dir}' must be a real directory owned by you (not a symlink). Refusing to mount N2C tokens." >&2 - exit 1 - fi - if [[ "$(stat -c '%a' -- "${dir}" 2> /dev/null || true)" != "700" ]]; then - echo "Error: ${what} '${dir}' must have mode 0700. Refusing to mount N2C tokens." >&2 - exit 1 - fi -} - -# System-managed credentials. A service account has no n2c-token session: a root -# service on the host (for example a timer that gets app-only tokens) keeps the -# same creds/NAME/token files up to date and the account only reads them. That -# layout is chosen when the credentials directory is owned by root, and is safe -# as long as nobody but root can change the directories or files on the way, so -# these checks replace "owned by you, mode 0700". +# The credentials are checked the way ssh checks a private key: a directory or +# token that someone else could read or replace is refused. The credentials +# directory belongs to you (n2c-token keeps your own session tokens there) or +# to root (a root service, for example a timer that gets app-only tokens with a +# certificate, keeps them up to date for a service account that only reads +# them). # _n2c_owner_mode PATH # Print "UID MODE" for PATH (MODE is the octal permission bits, for example @@ -1643,120 +1627,93 @@ _n2c_my_uid() { id -u } -# _n2c_root_owned_sealed PATH -# Return 0 when PATH is owned by root and neither its group nor others can -# write to it. -_n2c_root_owned_sealed() { - local om mode - om="$(_n2c_owner_mode "$1")" - [[ -n "${om}" && "${om%% *}" == "0" ]] || return 1 - mode="${om##* }" - [[ "${mode}" =~ ^[0-7]+$ ]] || return 1 - ((($((8#${mode})) & 8#022) == 0)) -} - -# _n2c_creds_layout DIR -# Print "user" when DIR is owned by the current user (n2c-token's own -# credentials) or "system" when it is owned by root (system-managed -# credentials). Return 1 with a message for a symlink, a missing -# directory or any other owner. -_n2c_creds_layout() { - local dir om uid - dir="$(_n2c_strip_slashes "$1")" - if [[ -L "${dir}" ]] || [[ ! -d "${dir}" ]]; then - echo "Error: N2C credentials directory '${dir}' must be a real directory (not a symlink). Refusing to mount N2C tokens." >&2 - return 1 +# _n2c_require_plain_path DIR WHAT +# Exit 1 unless DIR is an absolute path with no symlink, ".", ".." or doubled +# slash in it. The path is checked as written: a symlink anywhere in it could +# be changed between this check and the mount, which resolves the path again. +_n2c_require_plain_path() { + local p what="$2" canon + p="$(_n2c_strip_slashes "$1")" + if [[ "${p}" != /* ]]; then + echo "Error: ${what} '${p}' must be an absolute path. Refusing to mount N2C tokens." >&2 + exit 1 fi - om="$(_n2c_owner_mode "${dir}")" - uid="${om%% *}" - if [[ -n "${om}" && "${uid}" == "$(_n2c_my_uid)" ]]; then - echo "user" - elif [[ -n "${om}" && "${uid}" == "0" ]]; then - echo "system" - else - echo "Error: N2C credentials directory '${dir}' must be owned by you (n2c-token) or by root (system-managed tokens). Refusing to mount N2C tokens." >&2 - return 1 + canon="$(readlink -f -- "${p}" 2> /dev/null || true)" + if [[ "${canon}" != "${p}" ]]; then + echo "Error: ${what} '${p}' goes through a symlink or has a '.', '..' or doubled '/' in it (it resolves to '${canon}'). Give the real path. Refusing to mount N2C tokens." >&2 + exit 1 fi } -# _n2c_require_system_dir DIR WHAT -# Exit 1 unless DIR is a real directory (not a symlink), owned by root, not -# writable by its group or others, and one the current user can enter. -_n2c_require_system_dir() { - local dir what="$2" - dir="$(_n2c_strip_slashes "$1")" - if [[ -L "${dir}" ]] || [[ ! -d "${dir}" ]]; then - echo "Error: ${what} '${dir}' must be a real directory (not a symlink). Refusing to mount N2C tokens." >&2 +# _n2c_require_strict PATH WHAT +# Exit 1 unless PATH is owned by you or by root, is not writable by its group +# and has no permission at all for others. Read and enter by the group are +# allowed: that is how a service account reads tokens that root writes. +_n2c_require_strict() { + local path="$1" what="$2" om uid mode + om="$(_n2c_owner_mode "${path}")" + if [[ -z "${om}" ]]; then + echo "Error: cannot read the owner and mode of ${what} '${path}'. Refusing to mount N2C tokens." >&2 exit 1 fi - if ! _n2c_root_owned_sealed "${dir}"; then - echo "Error: ${what} '${dir}' must be owned by root and not writable by its group or others. Refusing to mount N2C tokens." >&2 + uid="${om%% *}" + mode="${om##* }" + if [[ "${uid}" != "0" && "${uid}" != "$(_n2c_my_uid)" ]]; then + echo "Error: ${what} '${path}' must be owned by you or by root. Refusing to mount N2C tokens." >&2 exit 1 fi - if [[ ! -x "${dir}" ]]; then - echo "Error: ${what} '${dir}' cannot be entered by you (check its group). Refusing to mount N2C tokens." >&2 + if [[ ! "${mode}" =~ ^[0-7]+$ ]] || (($((8#${mode})) & 8#027)); then + echo "Error: ${what} '${path}' has mode ${mode}: it must not be writable by its group or have any permission for others. Refusing to mount N2C tokens." >&2 exit 1 fi } -# _n2c_require_sealed_ancestors DIR WHAT -# Exit 1 unless DIR is an absolute path with no symlink (and no "." or ".." -# or doubled slash) in it, and every directory above it, up to "/", is owned -# by root and not writable by its group or others. The path is checked as -# written, not only after resolving it: a symlink anywhere in the path could -# be changed between this check and the mount, which resolves the path again, -# and a writable parent could be renamed and replaced by someone else's tree. -_n2c_require_sealed_ancestors() { - local what="$2" p canon - p="$(_n2c_strip_slashes "$1")" - if [[ "${p}" != /* ]]; then - echo "Error: ${what} '${p}' must be an absolute path. Refusing to mount N2C tokens." >&2 +# _n2c_require_private_dir DIR WHAT +# Exit 1 unless DIR is a real directory (not a symlink) that passes +# _n2c_require_strict and that you can enter. +_n2c_require_private_dir() { + local dir="$1" what="$2" + if [[ -L "${dir}" ]] || [[ ! -d "${dir}" ]]; then + echo "Error: ${what} '${dir}' must be a real directory (not a symlink). Refusing to mount N2C tokens." >&2 exit 1 fi - canon="$(readlink -f -- "${p}" 2> /dev/null || true)" - if [[ "${canon}" != "${p}" ]]; then - echo "Error: ${what} '${p}' goes through a symlink or has a '.', '..' or doubled '/' in it (it resolves to '${canon}'). Give the real path. Refusing to mount N2C tokens." >&2 + _n2c_require_strict "${dir}" "${what}" + if [[ ! -x "${dir}" ]]; then + echo "Error: ${what} '${dir}' cannot be entered by you (check its group). Refusing to mount N2C tokens." >&2 exit 1 fi - while [[ "${p}" != "/" ]]; do - p="${p%/*}" - [[ -n "${p}" ]] || p="/" - if ! _n2c_root_owned_sealed "${p}"; then - echo "Error: '${p}' above ${what} '$1' must be owned by root and not writable by its group or others. Refusing to mount N2C tokens." >&2 - exit 1 - fi - done } # build_n2c_token_mounts # Add the token files requested with --n2c-token to EXTRA_RO_PATHS, so # build_extra_path_mounts validates and mounts each one read-only at its own # path. Nothing is mounted unless --n2c-token was given. "all" selects every -# non-empty token under the credentials directory. n2c-token rewrites a token -# in place, so a mounted file sees every refresh. When at least one token is -# mounted, N2SN_CREDS_DIR (the canonical credentials directory) is set in the -# sandbox environment, because the host's location variables are not passed in. +# token directory under the credentials directory that you can enter. A +# token is rewritten in place (by n2c-token, or by the root service for a +# service account), so a mounted file sees every refresh. When at least one +# token is mounted, N2SN_CREDS_DIR (the canonical credentials directory) is +# set in the sandbox environment, because the host's location variables are +# not passed in. # -# A requested token that is missing or empty only warns (run `n2c-token -# login`). A credentials directory or token directory that is a symlink, is -# owned by someone else or is not mode 0700, or a token file that is a -# symlink or not a regular file, is an error: the launch stops. +# Checks, all errors that stop the launch: +# - N2SN_CREDS_DIR is an absolute path with no symlink in it; +# - the credentials directory, each token directory and each token are +# owned by you or by root, are not writable by their group and have no +# permission for others (like ssh and a private key); a token directory +# can be entered by you, and a token read by you; +# - the directories are real directories, and a token is a regular file, +# not a symlink. # -# Two layouts, chosen by who owns the credentials directory: -# user owned by you: n2c-token's own files, directories mode 0700. -# system owned by root: tokens kept up to date by a root service, for a -# service account. 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 you can -# read. An empty token is mounted too (the service fills the file -# in place, so it shows up as soon as it is written), and `all` -# skips token directories you cannot enter. +# A token with no file yet only warns. An empty token is mounted with a +# warning: the file is filled in place, so it shows up as soon as it is +# written. The hint in the warnings depends on who owns the credentials +# directory. # # Must be called BEFORE build_extra_path_mounts. build_n2c_token_mounts() { [[ -z "${N2C_TOKENS[*]+x}" ]] && return 0 - local root name tdir tfile want_all=0 + local root name tdir tfile want_all=0 hint om local -a names=() found=() local -A seen=() root="$(_n2c_creds_root)" @@ -1774,15 +1731,13 @@ build_n2c_token_mounts() { echo "Warning: N2C credentials directory '${root}' does not exist; no tokens mounted. Run: n2c-token login (or, for a service account, check that its token service has run)" >&2 return 0 fi - local layout hint - layout="$(_n2c_creds_layout "${root}")" || exit 1 - if [[ "${layout}" == "system" ]]; then - hint="These tokens are kept by a root service on this host; ask its administrator." - _n2c_require_system_dir "${root}" "N2C credentials directory" - _n2c_require_sealed_ancestors "${root}" "N2C credentials directory" - else + _n2c_require_plain_path "${root}" "N2C credentials directory" + _n2c_require_private_dir "${root}" "N2C credentials directory" + om="$(_n2c_owner_mode "${root}")" + if [[ "${om%% *}" == "$(_n2c_my_uid)" ]]; then hint="Run: n2c-token login" - _n2c_require_private "${root}" "N2C credentials directory" + else + hint="These tokens are kept by a root service on this host; ask its administrator." fi if [[ ${want_all} -eq 1 ]]; then @@ -1792,7 +1747,7 @@ build_n2c_token_mounts() { tdir="${tdir%/}" name="${tdir##*/}" [[ "${name}" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]] || continue - [[ "${layout}" == "system" && ! -x "${tdir}" ]] && continue + [[ -x "${tdir}" ]] || continue names+=("${name}") done fi @@ -1804,37 +1759,23 @@ build_n2c_token_mounts() { echo "Warning: no N2C token '${name}' (${tdir} does not exist); not mounted." >&2 continue fi - if [[ "${layout}" == "system" ]]; then - _n2c_require_system_dir "${tdir}" "N2C token directory" - else - _n2c_require_private "${tdir}" "N2C token directory" - fi + _n2c_require_private_dir "${tdir}" "N2C token directory" if [[ -L "${tfile}" ]] || { [[ -e "${tfile}" ]] && [[ ! -f "${tfile}" ]]; }; then echo "Error: N2C token '${tfile}' must be a regular file, not a symlink. Refusing to mount N2C tokens." >&2 exit 1 fi - if [[ "${layout}" == "system" ]]; then - if [[ ! -e "${tfile}" ]]; then - echo "Warning: N2C token '${name}' has no file yet (${tfile}); not mounted. ${hint}" >&2 - continue - fi - if ! _n2c_root_owned_sealed "${tfile}"; then - echo "Error: N2C token '${tfile}' must be owned by root and not writable by its group or others. Refusing to mount N2C tokens." >&2 - exit 1 - fi - if [[ ! -r "${tfile}" ]]; then - echo "Error: N2C token '${tfile}' cannot be read by you (check its group). Refusing to mount N2C tokens." >&2 - exit 1 - fi - if [[ ! -s "${tfile}" ]]; then - echo "Warning: N2C token '${name}' is empty for now; mounted, it shows up when written. ${hint}" >&2 - fi - elif [[ ! -s "${tfile}" ]]; then - if [[ ${want_all} -eq 0 ]]; then - echo "Warning: N2C token '${name}' is missing or empty; not mounted. ${hint}" >&2 - fi + if [[ ! -e "${tfile}" ]]; then + echo "Warning: N2C token '${name}' has no file yet (${tfile}); not mounted. ${hint}" >&2 continue fi + _n2c_require_strict "${tfile}" "N2C token" + if [[ ! -r "${tfile}" ]]; then + echo "Error: N2C token '${tfile}' cannot be read by you (check its group). Refusing to mount N2C tokens." >&2 + exit 1 + fi + if [[ ! -s "${tfile}" ]]; then + echo "Warning: N2C token '${name}' is empty for now; mounted, it shows up when written. ${hint}" >&2 + fi found+=("${tfile}") done From c7651d69f3bb3b159b9f8eba5cd990ed36c67efe Mon Sep 17 00:00:00 2001 From: "Stuart B. Wilkins" Date: Sun, 1 Nov 2026 08:28:07 -0500 Subject: [PATCH 4/6] fix(bwrap): check the parents of the credentials directory and keep one 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 --- docs/bw-wrappers.md | 18 ++++++---- lib/bwrap_sandbox_lib.sh | 78 ++++++++++++++++++++++++++++++---------- 2 files changed, 70 insertions(+), 26 deletions(-) diff --git a/docs/bw-wrappers.md b/docs/bw-wrappers.md index 471d20c..452af4b 100644 --- a/docs/bw-wrappers.md +++ b/docs/bw-wrappers.md @@ -154,16 +154,20 @@ the launch with an error: - `N2SN_CREDS_DIR` is an absolute path with no symlink, `.`, `..` or doubled `/` in it, so use the real path (`/run/...`, not `/var/run/...`): a symlink could be changed between the check and the mount. -- The creds directory, each token directory and each token are **owned by you - or by root**, are **not writable by their group**, and have **no permission - at all for others** (mode `0750`, `0700`, `0640`, `0600` or `0440` pass; - `0755`, `0775` or `0644` do not). Group read, and group enter for a - directory, are allowed. +- Every directory above it, up to `/`, is owned by you or by root and is not + writable by its group or others. Otherwise someone could rename a parent and + put their own tree, with a token you did not check, in its place. A + directory such as `/tmp`, which others can write to, is refused. +- The creds directory is **owned by you or by root**. Each token directory and + each token has **the same owner**, so a tree is yours throughout (n2c-token) + or root's throughout (a service account's tokens). +- The creds directory, each token directory and each token are **not writable + by their group** and have **no permission at all for others** (mode `0750`, + `0700`, `0640`, `0600` or `0440` pass; `0755`, `0775` or `0644` do not). + Group read, and group enter for a directory, are allowed. - The directories are real directories, you can enter a token directory and read its token, and a token is a regular file, not a symlink. -Directories above the creds directory are not checked: keep `N2SN_CREDS_DIR` -somewhere nobody else can write, such as `/run/user//...` or `/run/...`. `--n2c-token all` skips token directories you cannot enter. ### Service accounts diff --git a/lib/bwrap_sandbox_lib.sh b/lib/bwrap_sandbox_lib.sh index 3a4706a..834d3e7 100644 --- a/lib/bwrap_sandbox_lib.sh +++ b/lib/bwrap_sandbox_lib.sh @@ -1645,12 +1645,13 @@ _n2c_require_plain_path() { fi } -# _n2c_require_strict PATH WHAT -# Exit 1 unless PATH is owned by you or by root, is not writable by its group -# and has no permission at all for others. Read and enter by the group are -# allowed: that is how a service account reads tokens that root writes. +# _n2c_require_strict PATH WHAT [UID] +# Exit 1 unless PATH is owned by you or by root (or by UID, when given), is +# not writable by its group and has no permission at all for others. Read +# and enter by the group are allowed: that is how a service account reads +# tokens that root writes. _n2c_require_strict() { - local path="$1" what="$2" om uid mode + local path="$1" what="$2" want_uid="${3:-}" om uid mode om="$(_n2c_owner_mode "${path}")" if [[ -z "${om}" ]]; then echo "Error: cannot read the owner and mode of ${what} '${path}'. Refusing to mount N2C tokens." >&2 @@ -1658,7 +1659,12 @@ _n2c_require_strict() { fi uid="${om%% *}" mode="${om##* }" - if [[ "${uid}" != "0" && "${uid}" != "$(_n2c_my_uid)" ]]; then + if [[ -n "${want_uid}" ]]; then + if [[ "${uid}" != "${want_uid}" ]]; then + echo "Error: ${what} '${path}' must have the same owner as the credentials directory (uid ${want_uid}), not uid ${uid}. Refusing to mount N2C tokens." >&2 + exit 1 + fi + elif [[ "${uid}" != "0" && "${uid}" != "$(_n2c_my_uid)" ]]; then echo "Error: ${what} '${path}' must be owned by you or by root. Refusing to mount N2C tokens." >&2 exit 1 fi @@ -1668,22 +1674,51 @@ _n2c_require_strict() { fi } -# _n2c_require_private_dir DIR WHAT +# _n2c_require_private_dir DIR WHAT [UID] # Exit 1 unless DIR is a real directory (not a symlink) that passes -# _n2c_require_strict and that you can enter. +# _n2c_require_strict (with UID, when given) and that you can enter. _n2c_require_private_dir() { - local dir="$1" what="$2" + local dir="$1" what="$2" want_uid="${3:-}" if [[ -L "${dir}" ]] || [[ ! -d "${dir}" ]]; then echo "Error: ${what} '${dir}' must be a real directory (not a symlink). Refusing to mount N2C tokens." >&2 exit 1 fi - _n2c_require_strict "${dir}" "${what}" + _n2c_require_strict "${dir}" "${what}" "${want_uid}" if [[ ! -x "${dir}" ]]; then echo "Error: ${what} '${dir}' cannot be entered by you (check its group). Refusing to mount N2C tokens." >&2 exit 1 fi } +# _n2c_require_sealed_ancestors DIR WHAT +# Exit 1 unless every directory above DIR, up to "/", is owned by you or by +# root and not writable by its group or others. DIR must already have passed +# _n2c_require_plain_path, so these are its real parents. Otherwise someone +# could rename a parent and put their own tree, and an unchecked token, in its +# place before the mount resolves the path again. A directory such as /tmp, +# which others can write to (even with the sticky bit), is refused. +_n2c_require_sealed_ancestors() { + local p="$1" what="$2" om uid mode me bad + me="$(_n2c_my_uid)" + while [[ "${p}" != "/" ]]; do + p="${p%/*}" + [[ -n "${p}" ]] || p="/" + om="$(_n2c_owner_mode "${p}")" + uid="${om%% *}" + mode="${om##* }" + bad=0 + [[ -n "${om}" && ("${uid}" == "0" || "${uid}" == "${me}") ]] || bad=1 + [[ "${mode}" =~ ^[0-7]+$ ]] || bad=1 + if [[ ${bad} -eq 0 ]] && (($((8#${mode})) & 8#022)); then + bad=1 + fi + if [[ ${bad} -eq 1 ]]; then + echo "Error: '${p}' above ${what} must be owned by you or by root and not writable by its group or others. Refusing to mount N2C tokens." >&2 + exit 1 + fi + done +} + # build_n2c_token_mounts # Add the token files requested with --n2c-token to EXTRA_RO_PATHS, so # build_extra_path_mounts validates and mounts each one read-only at its own @@ -1696,11 +1731,14 @@ _n2c_require_private_dir() { # not passed in. # # Checks, all errors that stop the launch: -# - N2SN_CREDS_DIR is an absolute path with no symlink in it; -# - the credentials directory, each token directory and each token are -# owned by you or by root, are not writable by their group and have no -# permission for others (like ssh and a private key); a token directory -# can be entered by you, and a token read by you; +# - N2SN_CREDS_DIR is an absolute path with no symlink in it, and every +# directory above it is owned by you or by root and not writable by +# group or others; +# - the credentials directory is owned by you or by root, and each token +# directory and each token has the same owner; +# - all of them are not writable by their group and have no permission +# for others (like ssh and a private key); a token directory can be +# entered by you, and a token read by you; # - the directories are real directories, and a token is a regular file, # not a symlink. # @@ -1713,7 +1751,7 @@ _n2c_require_private_dir() { build_n2c_token_mounts() { [[ -z "${N2C_TOKENS[*]+x}" ]] && return 0 - local root name tdir tfile want_all=0 hint om + local root name tdir tfile want_all=0 hint om root_uid local -a names=() found=() local -A seen=() root="$(_n2c_creds_root)" @@ -1732,9 +1770,11 @@ build_n2c_token_mounts() { return 0 fi _n2c_require_plain_path "${root}" "N2C credentials directory" + _n2c_require_sealed_ancestors "${root}" "the N2C credentials directory" _n2c_require_private_dir "${root}" "N2C credentials directory" om="$(_n2c_owner_mode "${root}")" - if [[ "${om%% *}" == "$(_n2c_my_uid)" ]]; then + root_uid="${om%% *}" + if [[ "${root_uid}" == "$(_n2c_my_uid)" ]]; then hint="Run: n2c-token login" else hint="These tokens are kept by a root service on this host; ask its administrator." @@ -1759,7 +1799,7 @@ build_n2c_token_mounts() { echo "Warning: no N2C token '${name}' (${tdir} does not exist); not mounted." >&2 continue fi - _n2c_require_private_dir "${tdir}" "N2C token directory" + _n2c_require_private_dir "${tdir}" "N2C token directory" "${root_uid}" if [[ -L "${tfile}" ]] || { [[ -e "${tfile}" ]] && [[ ! -f "${tfile}" ]]; }; then echo "Error: N2C token '${tfile}' must be a regular file, not a symlink. Refusing to mount N2C tokens." >&2 exit 1 @@ -1768,7 +1808,7 @@ build_n2c_token_mounts() { echo "Warning: N2C token '${name}' has no file yet (${tfile}); not mounted. ${hint}" >&2 continue fi - _n2c_require_strict "${tfile}" "N2C token" + _n2c_require_strict "${tfile}" "N2C token" "${root_uid}" if [[ ! -r "${tfile}" ]]; then echo "Error: N2C token '${tfile}' cannot be read by you (check its group). Refusing to mount N2C tokens." >&2 exit 1 From 896f5a8fbba4643b63b56322ebfc2b26f27e057a Mon Sep 17 00:00:00 2001 From: "Stuart B. Wilkins" Date: Fri, 30 Oct 2026 04:58:15 -0400 Subject: [PATCH 5/6] fix(bwrap): resolve symlinks and check overlap of mount sources 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 47f1865f7) --- docs/bw-wrappers.md | 6 ++++-- lib/bwrap_sandbox_lib.sh | 29 +++++++++++++++++++---------- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/docs/bw-wrappers.md b/docs/bw-wrappers.md index 452af4b..b3bea5b 100644 --- a/docs/bw-wrappers.md +++ b/docs/bw-wrappers.md @@ -126,8 +126,10 @@ NSLS-II API in `/run/user//nsls2/creds//token`, for example - **Automatic mounts cannot expose it either.** Directories the launcher mounts whole (the npm and pixi caches, ccache, the tool data and cache directories) are only checked for being blocked themselves. So, after all mounts are built, - the launch stops with an error if any bind mount would contain the N2C session - directory, for example with `N2C_STATE_DIR` set inside `~/.npm`. The default + the launch stops with an error if any bind mount's source (as written or with + symlinks resolved) is, contains or lies inside the N2C session directory, for + example with `N2C_STATE_DIR` set inside `~/.npm`, or `~/.npm` being a symlink to + a directory that holds it. The default location, under `/run/user/`, is never inside such a directory. - **The tokens are checked like an ssh private key** (see [Checks](#checks-on-the-credentials) below): a directory or token that diff --git a/lib/bwrap_sandbox_lib.sh b/lib/bwrap_sandbox_lib.sh index 834d3e7..cf3098f 100644 --- a/lib/bwrap_sandbox_lib.sh +++ b/lib/bwrap_sandbox_lib.sh @@ -1844,27 +1844,36 @@ build_n2c_token_mounts() { # (npm and pixi caches, ccache, tool data and cache directories) are only # checked for being blocked themselves, so with N2C_STATE_DIR set inside one of # them the session directory would be mounted along with it. This looks at the -# finished mount list, so it covers every mount whatever added it. Nothing is -# checked while the directory does not exist. +# finished mount list, so it covers every mount whatever added it. A bind +# counts when its source, as written or with symlinks resolved, is the session +# directory, contains it or lies inside it, compared against the session +# directory as written and resolved. Nothing is checked while the directory +# does not exist. # # Called by print_dry_run and launch_sandbox, after all mounts are built. verify_n2c_state_not_exposed() { - local state canon src flag i=0 + local state state_canon src src_canon flag s t i=0 state="$(_n2c_state_dir)" [[ -e "${state}" ]] || return 0 - canon="$(readlink -f -- "${state}" 2> /dev/null || true)" + state_canon="$(readlink -f -- "${state}" 2> /dev/null || true)" while [[ ${i} -lt ${#BWRAP_ARGS[@]} ]]; do flag="${BWRAP_ARGS[${i}]}" _bwrap_flag_arity "${flag}" case "${flag}" in --bind | --bind-try | --ro-bind | --ro-bind-try | --dev-bind | --dev-bind-try) src="${BWRAP_ARGS[$((i + 1))]}" - if _is_prefix_of "${src}" "${state}" || - { [[ -n "${canon}" ]] && _is_prefix_of "${src}" "${canon}"; }; then - echo "Error: the mount of '${src}' would expose the N2C session directory '${state}' (it holds the refresh token)." >&2 - echo " Move N2C_STATE_DIR out of directories that the launcher mounts (for example ~/.npm, ~/.pixi, ~/.ccache)." >&2 - exit 1 - fi + src_canon="$(readlink -f -- "${src}" 2> /dev/null || true)" + for s in "${src}" "${src_canon}"; do + [[ -n "${s}" ]] || continue + for t in "${state}" "${state_canon}"; do + [[ -n "${t}" ]] || continue + if _is_prefix_of "${s}" "${t}" || _is_prefix_of "${t}" "${s}"; then + echo "Error: the mount of '${src}' would expose the N2C session directory '${state}' (it holds the refresh token)." >&2 + echo " Move N2C_STATE_DIR out of directories that the launcher mounts (for example ~/.npm, ~/.pixi, ~/.ccache)." >&2 + exit 1 + fi + done + done ;; esac i=$((i + 1 + _BWRAP_FLAG_ARITY)) From 9b53805d014b3107c6c35c20bc7f2a65a871997d Mon Sep 17 00:00:00 2001 From: "Stuart B. Wilkins" Date: Wed, 4 Nov 2026 07:35:29 -0500 Subject: [PATCH 6/6] feat(bwopencode2): forward AIFAPIM_TOKEN_FILE for the gateway token plugin 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 --- bin/bwopencode2 | 6 ++++++ docs/bw-wrappers.md | 27 ++++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/bin/bwopencode2 b/bin/bwopencode2 index 185600a..3e47c8f 100755 --- a/bin/bwopencode2 +++ b/bin/bwopencode2 @@ -667,6 +667,8 @@ build_opencode2_state_dir() { # redirect data/config/model sources outside the sandbox layout # OPENCODE_API_KEY credentials # Also pass through AIFAPIM_* env vars for NSLS-II Hermes gateway. +# AIFAPIM_TOKEN_FILE (the Entra ID token for the gateway, read by the aifapim-entra +# plugin) is passed through too; the token itself comes in with --n2c-token aifapim. # With --github-tokens, also forward GH_TOKEN_* vars so the agent # can authenticate gh(1) via per-command: # GH_TOKEN="$GH_TOKEN_NSLS2" gh @@ -716,6 +718,10 @@ build_opencode2_env() { # The API key is passed through unchanged; print_dry_run redacts the value # of any variable whose name looks secret (KEY/TOKEN/PASSWORD/...). pass_through_if_set AIFAPIM_API_KEY + # Path of the gateway's Entra ID token file, for the aifapim-entra plugin. It is a path, + # not the token; the file is mounted by --n2c-token aifapim. The plugin does nothing + # unless this and AIFAPIM_HOST are both set. + pass_through_if_set AIFAPIM_TOKEN_FILE local _key _allowed _name local -a _dropped=() diff --git a/docs/bw-wrappers.md b/docs/bw-wrappers.md index b3bea5b..6acae9c 100644 --- a/docs/bw-wrappers.md +++ b/docs/bw-wrappers.md @@ -191,6 +191,28 @@ each token as `root:GROUP` with mode `0440`, where `GROUP` is the group of the account that runs the sandbox, and rewrite a token in place (the same file) rather than replace it, because the sandbox mounts the file itself. +#### The AI gateway token + +A service account that sends its requests to the AIFAPIM gateway through an +agents product needs a second credential next to its subscription key +(`AIFAPIM_API_KEY`, sent as `x-api-key`): an Entra ID token, sent as +`Authorization: Bearer`. The root service keeps that token in the same layout, +as the token named `aifapim`, so it is mounted like any other: + +```bash +export N2SN_CREDS_DIR=/run/nsls2/agent/creds +export AIFAPIM_HOST=aifapim.nsls2.bnl.gov # gitleaks:allow (a public host name) +export AIFAPIM_TOKEN_FILE=/run/nsls2/agent/creds/aifapim/token +bwopencode2 --n2c-token aifapim --n2c-token observability +``` + +`bwopencode2` passes `AIFAPIM_TOKEN_FILE` into the sandbox, which is a path and +not the token. The `aifapim-entra` OpenCode 2 plugin (NSLS2/n2snskills, +`plugins/`) reads the file on every request, because the token changes every 15 +minutes, and does nothing unless both `AIFAPIM_HOST` and `AIFAPIM_TOKEN_FILE` +are set. `bwopencode` does not forward the variable: OpenCode 1 does not load +the plugin. + ## Sandbox context An agent that does not know it runs in a sandbox guesses: it takes a read-only @@ -461,7 +483,10 @@ system-wide `/etc/opencode/opencode.json{,c}` (shared with `bwopencode`), or an `~/.config/opencode2/opencode`. If neither exists the wrapper exits with status 2. The OpenCode 1 config in `~/.config/opencode` is not used. -Accepts the common options above except `--init-auth`. +Accepts the common options above except `--init-auth`. Of the host's +environment it forwards `AIFAPIM_HOST`, `AIFAPIM_API_KEY` and +`AIFAPIM_TOKEN_FILE` (see "The AI gateway token" above), and only the +`OPENCODE_*` variables on the allowlist below. ### Host directories and sandbox locations