diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 8feb8f0..3abb591 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -25,7 +25,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false @@ -36,9 +36,7 @@ jobs: pixi-version: v0.71.3 cache: false frozen: true - - - name: Install dev environment - run: pixi install -e dev + environments: dev - name: Determine changed-file ref range id: refs @@ -52,44 +50,98 @@ jobs: echo "from_ref=origin/${BASE_REF}" >> "$GITHUB_OUTPUT" echo "to_ref=${SHA}" >> "$GITHUB_OUTPUT" elif [[ "$EVENT_NAME" == "push" ]]; then - echo "from_ref=${BEFORE}" >> "$GITHUB_OUTPUT" - echo "to_ref=${SHA}" >> "$GITHUB_OUTPUT" + # On branch creation, $BEFORE is 40 zeros; after force-push, the old SHA + # may be unfetchable. Both break prek --from-ref, so fall back to --all-files. + if [[ "$BEFORE" =~ ^0+$ ]] || ! git cat-file -e "$BEFORE" 2>/dev/null; then + echo "from_ref=" >> "$GITHUB_OUTPUT" + echo "to_ref=" >> "$GITHUB_OUTPUT" + else + echo "from_ref=${BEFORE}" >> "$GITHUB_OUTPUT" + echo "to_ref=${SHA}" >> "$GITHUB_OUTPUT" + fi else # workflow_dispatch: lint everything echo "from_ref=" >> "$GITHUB_OUTPUT" echo "to_ref=" >> "$GITHUB_OUTPUT" fi - - name: Run pre-commit (changed files) - id: pre_commit + - name: Collect tool versions + id: versions + run: | + # Extract versions from pixi dev environment + RUFF_VER=$(pixi list -e dev --json | jq -r '.[] | select(.name=="ruff") | .version') + MYPY_VER=$(pixi list -e dev --json | jq -r '.[] | select(.name=="mypy") | .version') + CODESPELL_VER=$(pixi list -e dev --json | jq -r '.[] | select(.name=="codespell") | .version') + YAMLLINT_VER=$(pixi list -e dev --json | jq -r '.[] | select(.name=="yamllint") | .version') + ZIZMOR_VER=$(pixi list -e dev --json | jq -r '.[] | select(.name=="zizmor") | .version') + + # Extract versions from .pre-commit-config.yaml rev: tags + # (strip wrapper-specific build suffixes where applicable) + PRECOMMIT_HOOKS_VER=$(grep -A1 'repo: https://github.com/pre-commit/pre-commit-hooks' .pre-commit-config.yaml | grep 'rev:' | awk '{print $2}') + MARKDOWNLINT_VER=$(grep -A1 'repo: https://github.com/igorshubovych/markdownlint-cli' .pre-commit-config.yaml | grep 'rev:' | awk '{print $2}') + ACTIONLINT_VER=$(grep -A1 'repo: https://github.com/rhysd/actionlint' .pre-commit-config.yaml | grep 'rev:' | awk '{print $2}') + # shfmt: pre-commit-shfmt adds -N build numbers (v3.12.0-2 -> v3.12.0) + SHFMT_VER=$(grep -A1 'repo: https://github.com/scop/pre-commit-shfmt' .pre-commit-config.yaml | grep 'rev:' | awk '{print $2}' | sed 's/-[0-9]\+$//') + # eratta: shellcheck-py: Python wrapper adds .N to upstream shellcheck version (v0.11.0.1 -> v0.11.0) + SHELLCHECK_VER=$(grep -A1 'repo: https://github.com/shellcheck-py/shellcheck-py' .pre-commit-config.yaml | grep 'rev:' | awk '{print $2}' | sed 's/\.[0-9]\+$//') + GITLEAKS_VER=$(grep -A1 'repo: https://github.com/gitleaks/gitleaks' .pre-commit-config.yaml | grep 'rev:' | awk '{print $2}') + + # Write to GITHUB_OUTPUT + { + echo "ruff=$RUFF_VER" + echo "mypy=$MYPY_VER" + echo "codespell=$CODESPELL_VER" + echo "yamllint=$YAMLLINT_VER" + echo "zizmor=$ZIZMOR_VER" + echo "precommit_hooks=$PRECOMMIT_HOOKS_VER" + echo "markdownlint=$MARKDOWNLINT_VER" + echo "actionlint=$ACTIONLINT_VER" + echo "shfmt=$SHFMT_VER" + echo "shellcheck=$SHELLCHECK_VER" + echo "gitleaks=$GITLEAKS_VER" + } >> "$GITHUB_OUTPUT" + + - name: Run prek (changed files) + id: prek env: FROM_REF: ${{ steps.refs.outputs.from_ref }} TO_REF: ${{ steps.refs.outputs.to_ref }} run: | set -o pipefail if [[ -n "$FROM_REF" ]]; then - pixi run -e dev pre-commit run \ + pixi run -e dev prek run \ --from-ref "$FROM_REF" \ --to-ref "$TO_REF" \ --show-diff-on-failure \ --color always \ - 2>&1 | tee /tmp/pre-commit-output.txt + 2>&1 | tee /tmp/prek-output.txt else - pixi run -e dev pre-commit run \ + pixi run -e dev prek run \ --all-files \ --show-diff-on-failure \ --color always \ - 2>&1 | tee /tmp/pre-commit-output.txt + 2>&1 | tee /tmp/prek-output.txt fi - name: Write job summary if: always() env: - PRE_COMMIT_OUTCOME: ${{ steps.pre_commit.outcome }} + PREK_OUTCOME: ${{ steps.prek.outcome }} FROM_REF: ${{ steps.refs.outputs.from_ref }} TO_REF: ${{ steps.refs.outputs.to_ref }} + RUFF_VER: ${{ steps.versions.outputs.ruff }} + MYPY_VER: ${{ steps.versions.outputs.mypy }} + CODESPELL_VER: ${{ steps.versions.outputs.codespell }} + YAMLLINT_VER: ${{ steps.versions.outputs.yamllint }} + ZIZMOR_VER: ${{ steps.versions.outputs.zizmor }} + PRECOMMIT_HOOKS_VER: ${{ steps.versions.outputs.precommit_hooks }} + MARKDOWNLINT_VER: ${{ steps.versions.outputs.markdownlint }} + ACTIONLINT_VER: ${{ steps.versions.outputs.actionlint }} + SHFMT_VER: ${{ steps.versions.outputs.shfmt }} + SHELLCHECK_VER: ${{ steps.versions.outputs.shellcheck }} + GITLEAKS_VER: ${{ steps.versions.outputs.gitleaks }} run: | - if [[ "$PRE_COMMIT_OUTCOME" == "success" ]]; then + if [[ "$PREK_OUTCOME" == "success" ]]; then BADGE="All linting hooks passed" else BADGE="One or more linting hooks failed" @@ -112,23 +164,23 @@ jobs: echo "" echo "| Hook | Tool | Version |" echo "| ---- | ---- | ------- |" - echo "| trailing-whitespace / end-of-file-fixer / check-* | pre-commit-hooks | v5.0.0 |" - echo "| ruff check + ruff format | ruff | 0.15.8 |" - echo "| mypy | mypy | 2.1.0 |" - echo "| codespell | codespell | 2.4.2 |" - echo "| markdownlint | markdownlint-cli | v0.48.0 |" - echo "| yamllint | yamllint | 1.38.0 |" - echo "| actionlint | actionlint | v1.7.12 |" - echo "| zizmor | zizmor | 1.23.1 |" - echo "| shfmt | shfmt | v3.12.0 |" - echo "| shellcheck | shellcheck | v0.11.0 |" - echo "| gitleaks | gitleaks | v8.30.1 |" + echo "| trailing-whitespace / end-of-file-fixer / check-* | pre-commit-hooks | ${PRECOMMIT_HOOKS_VER} |" + echo "| ruff check + ruff format | ruff | ${RUFF_VER} |" + echo "| mypy | mypy | ${MYPY_VER} |" + echo "| codespell | codespell | ${CODESPELL_VER} |" + echo "| markdownlint | markdownlint-cli | ${MARKDOWNLINT_VER} |" + echo "| yamllint | yamllint | ${YAMLLINT_VER} |" + echo "| actionlint | actionlint | ${ACTIONLINT_VER} |" + echo "| zizmor | zizmor | ${ZIZMOR_VER} |" + echo "| shfmt | shfmt | ${SHFMT_VER} |" + echo "| shellcheck | shellcheck | ${SHELLCHECK_VER} |" + echo "| gitleaks | gitleaks | ${GITLEAKS_VER} |" echo "" - if [[ -s /tmp/pre-commit-output.txt ]]; then + if [[ -s /tmp/prek-output.txt ]]; then echo "### Output" echo "" echo '```' - sed 's/\x1b\[[0-9;]*m//g' /tmp/pre-commit-output.txt + sed 's/\x1b\[[0-9;]*m//g' /tmp/prek-output.txt echo '```' fi } >> "$GITHUB_STEP_SUMMARY" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b8f0c24..24abc86 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,7 +3,7 @@ # commit and in CI via the Lint workflow # (.github/workflows/lint.yml). Tool versions are pinned in # pixi.toml [feature.dev.dependencies] for the pixi-dispatched hooks -# and via `rev:` for the upstream pre-commit-managed hooks. +# and via `rev:` for the upstream prek-managed hooks. # # Linter configs live at the repository root and are auto-discovered # by each tool by its conventional dotfile name. The only exception is diff --git a/README.md b/README.md index d2ebf32..78ec05a 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,6 @@ Add those three exports to your `~/.bashrc` or `~/.zshrc` for persistence. - `bash` >= 4.x (the `bw*` wrappers use `[[ ]]` and arrays) - `bubblewrap` (`bwrap`) >= 0.4.0 for the `bw*` wrappers - - 0.5.0+ enables `--clearenv` - 0.6.3+ enables bind-over-ro-bind binary masking - `gpg(1)` for `gpg-passwd.sh` - `openssl(1)` for `pemdecompose` @@ -92,11 +91,13 @@ Common wrapper options: | Option | Effect | | --- | --- | -| `--help`, `-h` | Show wrapper help plus the underlying tool's help | +| `--help`, `-h` | Show wrapper help, then the underlying tool's help when the tool is installed | | `--dry-run` | Print the `bwrap` command without executing it | -| `--exec CMD` | Run `CMD` inside the sandbox instead of the tool | +| `--exec CMD [ARGS...]` | Run `CMD` inside the sandbox instead of the tool. `CMD` is split on whitespace; following arguments are passed unchanged, e.g. `--exec bash -c 'env \| sort'` | | `--init-auth` | Persist auth credentials to the host (first-time setup) | | `--new-session` | Force `bwrap --new-session` (stricter isolation; breaks SIGWINCH) | +| `--ro-path PATH`, `--rw-path PATH` | Mount an existing path read-only / read-write (may be repeated and nested) | +| `--github-tokens` | Forward `GH_TOKEN_*` variables so `gh` can authenticate inside the sandbox (off by default) | `bwclaude` also supports `--debug`. `bwopencode2` has no `--init-auth` (sign-in persists in its data directory), always runs OpenCode 2 with `--standalone` diff --git a/bin/azoidcapp b/bin/azoidcapp index 3d18332..cd37bb6 100755 --- a/bin/azoidcapp +++ b/bin/azoidcapp @@ -42,6 +42,8 @@ from __future__ import annotations import argparse import json +import os +import socket import subprocess import sys import time @@ -124,26 +126,6 @@ def _run(args: list[str]) -> str: # --------------------------------------------------------------------------- -def check_az_login() -> None: - """Verify that 'az' is on PATH and a session is active.""" - try: - subprocess.run( - ["az", "account", "show"], - check=True, - capture_output=True, - ) - except FileNotFoundError: - die( - "'az' CLI not found on PATH. Install the Azure CLI: https://aka.ms/azure-cli", - code=2, - ) - except subprocess.CalledProcessError: - die( - "No active az session. Run 'az login' first.", - code=2, - ) - - def get_graph_token(tenant: str | None) -> str: """Obtain a Microsoft Graph bearer token via the az CLI.""" cmd = [ @@ -170,16 +152,29 @@ def get_graph_token(tenant: str | None) -> str: # --------------------------------------------------------------------------- +def _odata_str(value: str) -> str: + """ + Escape a string for safe interpolation into an OData filter literal. + + OData escapes a single quote by doubling it. Callers still URL-encode + the assembled filter expression (see _filter_path); this handles the + OData syntax layer so an apostrophe in a value cannot break the filter + or alter its semantics. + """ + return value.replace("'", "''") + + def _filter_path(resource: str, filter_expr: str, select: str | None = None) -> str: """ Build a Graph list path with a properly encoded $filter (and $select). - Encoding the filter value prevents urllib from rejecting URLs that + Encoding the query values prevents urllib from rejecting URLs that contain spaces or single-quotes (e.g. OData 'eq' expressions). """ - qs = f"$filter={urllib.parse.quote(filter_expr)}" + params = {"$filter": filter_expr} if select: - qs += f"&$select={urllib.parse.quote(select)}" + params["$select"] = select + qs = urllib.parse.urlencode(params, quote_via=urllib.parse.quote) return f"/{resource}?{qs}" @@ -196,20 +191,34 @@ def _parse_error(exc: urllib.error.HTTPError) -> tuple[int, str]: return exc.code, detail or f"HTTP {exc.code}" +# Non-HTTPError exceptions from a Graph call that still mean the call +# failed (network unreachable, timeout, or an unparsable response body). +# NOTE: HTTPError is a subclass of URLError, so any except clause using +# this tuple MUST catch urllib.error.HTTPError first. +_GRAPH_CALL_ERRORS = ( + urllib.error.URLError, + TimeoutError, + socket.timeout, + json.JSONDecodeError, +) + +# Network timeout (seconds) for a single Graph REST call. +_GRAPH_TIMEOUT = 30 + + def _graph_request( method: str, path: str, token: str, body: dict[str, Any] | None = None, - *, - expect_no_content: bool = False, ) -> dict[str, Any] | None: """ Make a single Microsoft Graph REST call. Returns the parsed JSON body on success, or None when the server - returns 204 No Content (or expect_no_content is True). - Raises urllib.error.HTTPError on HTTP failure. + returns an empty body (e.g. 204 No Content). + Raises urllib.error.HTTPError on HTTP failure; URLError/TimeoutError/ + json.JSONDecodeError on network or response-decoding failure. """ url = f"{GRAPH_BASE}{path}" data = json.dumps(body).encode() if body is not None else None @@ -219,9 +228,9 @@ def _graph_request( "Accept": "application/json", } req = urllib.request.Request(url, data=data, headers=headers, method=method) - with urllib.request.urlopen(req) as resp: + with urllib.request.urlopen(req, timeout=_GRAPH_TIMEOUT) as resp: raw = resp.read() - if expect_no_content or not raw: + if not raw: return None return json.loads(raw) @@ -232,16 +241,37 @@ def graph( token: str, body: dict[str, Any] | None = None, *, - expect_no_content: bool = False, description: str = "", ) -> dict[str, Any] | None: - """Graph call; dies with code 4 on HTTP failure.""" + """Graph call; dies with code 4 on HTTP, network, or decode failure.""" label = description or f"{method} {path}" try: - return _graph_request(method, path, token, body, expect_no_content=expect_no_content) + return _graph_request(method, path, token, body) except urllib.error.HTTPError as exc: code, detail = _parse_error(exc) die(f"{label} failed ({code}): {detail}", code=4) + except _GRAPH_CALL_ERRORS as exc: + die(f"{label} failed: {exc}", code=4) + + +def graph_json( + method: str, + path: str, + token: str, + body: dict[str, Any] | None = None, + *, + description: str = "", +) -> dict[str, Any]: + """ + Graph call that requires a JSON object response. + + Dies with code 4 on failure or when the server returns an empty body. + """ + label = description or f"{method} {path}" + resp = graph(method, path, token, body, description=description) + if resp is None: + die(f"{label} failed: expected a response body but got none", code=4) + return resp def _is_already_exists_error(http_code: int, message: str) -> bool: @@ -256,44 +286,48 @@ def _is_already_exists_error(http_code: int, message: str) -> bool: return True if http_code == 400: msg_lower = message.lower() - return any( - phrase in msg_lower - for phrase in ( - # "already exist" (no trailing 's') matches both: - # "One or more added object references already exist - # for the following modified properties: 'owners'." - # "Permission being assigned already exists." - "already exist", - "object references already exist", - ) - ) + # "already exist" (no trailing 's') matches both: + # "One or more added object references already exist for the + # following modified properties: 'owners'." + # "Permission being assigned already exists." + return "already exist" in msg_lower return False -def graph_or_exists( - method: str, - path: str, - token: str, - body: dict[str, Any] | None = None, - *, - expect_no_content: bool = False, - description: str = "", -) -> tuple[dict[str, Any] | None, bool]: +def _is_sp_propagation_error(http_code: int, message: str) -> bool: """ - Graph call that treats 'already exists' responses as success. + Return True when a 400 is a transient service-principal replication lag. - Returns (response_body_or_None, already_existed). - Dies with code 4 on any other HTTP failure. + After a service principal is created, Microsoft Graph can take up to + ~60 s to make it visible to the admin-consent / grant endpoints, which + fail with a 400 whose message mentions the SP not existing yet. Only + those 400s are worth retrying; permanent 400s (bad payloads) are not. """ - label = description or f"{method} {path}" - try: - resp = _graph_request(method, path, token, body, expect_no_content=expect_no_content) - return resp, False - except urllib.error.HTTPError as exc: - code, detail = _parse_error(exc) - if _is_already_exists_error(code, detail): - return None, True - die(f"{label} failed ({code}): {detail}", code=4) + if http_code != 400: + return False + msg_lower = message.lower() + return any( + phrase in msg_lower + for phrase in ( + "does not exist", + "does not reference a valid application", # appId propagation lag + "was not found", + "cannot find", + "service principal", + "replicat", # "replication", "not yet replicated" + ) + ) + + +def _retry_after_seconds(exc: urllib.error.HTTPError, default: float) -> float: + """Parse a Retry-After response header (delta-seconds); fall back to default.""" + value = exc.headers.get("Retry-After") if exc.headers else None + if value: + try: + return max(0.0, float(value.strip())) + except (TypeError, ValueError): + pass + return default def graph_with_retry( @@ -302,26 +336,29 @@ def graph_with_retry( token: str, body: dict[str, Any] | None = None, *, - expect_no_content: bool = False, description: str = "", retries: int = 6, backoff: float = 10.0, swallow_exists: bool = False, ) -> tuple[dict[str, Any] | None, bool]: """ - Graph call with exponential backoff on transient errors, optionally - treating 'already exists' as success. + Graph call with linear backoff on transient errors, optionally treating + 'already exists' as success. After creating a service principal, Microsoft Graph can take up to ~60 s to make the new SP visible to all endpoints (admin-consent, - role assignment, etc.). Retrying with a linear backoff handles this. + role assignment, etc.). Retrying with a linear backoff (backoff * attempt) + handles this. Retries on 404/429/5xx, and on a 400 only when its message + indicates SP-replication lag; honours a Retry-After response header. Returns (response_body_or_None, already_existed). """ label = description or f"{method} {path}" + # Status codes that are always worth retrying (transient/throttling). + retry_codes = (404, 429, 500, 502, 503, 504) for attempt in range(1, retries + 1): try: - resp = _graph_request(method, path, token, body, expect_no_content=expect_no_content) + resp = _graph_request(method, path, token, body) return resp, False except urllib.error.HTTPError as exc: code, detail = _parse_error(exc) @@ -329,15 +366,36 @@ def graph_with_retry( if swallow_exists and _is_already_exists_error(code, detail): return None, True - if code in (400, 404, 503) and attempt < retries: - wait = backoff * attempt + retryable = code in retry_codes or _is_sp_propagation_error(code, detail) + if retryable and attempt < retries: + wait = _retry_after_seconds(exc, backoff * attempt) warn(f"{label}: HTTP {code} (attempt {attempt}/{retries}), retrying in {wait:.0f}s\u2026") time.sleep(wait) continue die(f"{label} failed ({code}): {detail}", code=4) + except _GRAPH_CALL_ERRORS as exc: + if attempt < retries: + wait = backoff * attempt + warn(f"{label}: {exc} (attempt {attempt}/{retries}), retrying in {wait:.0f}s\u2026") + time.sleep(wait) + continue + die(f"{label} failed: {exc}", code=4) + + # Unreachable: the loop always either returns or dies on the final attempt. + raise AssertionError("graph_with_retry exhausted without returning") - die(f"{label}: all {retries} attempts failed", code=4) + +def _relativize_graph_url(url: str) -> str: + """Convert an absolute Graph @odata.nextLink to a GRAPH_BASE-relative path.""" + if url.startswith(GRAPH_BASE): + return url[len(GRAPH_BASE) :] + parsed = urllib.parse.urlsplit(url) + base_path = urllib.parse.urlsplit(GRAPH_BASE).path # e.g. "/v1.0" + path = parsed.path + if base_path and path.startswith(base_path): + path = path[len(base_path) :] + return urllib.parse.urlunsplit(("", "", path, parsed.query, "")) def graph_list( @@ -350,11 +408,19 @@ def graph_list( """ Return all items from a Graph list endpoint matching an OData filter. - Uses a properly encoded URL to avoid InvalidURL errors on spaces/quotes. + Follows @odata.nextLink so every page is accumulated, not just the + first. Uses a properly encoded URL to avoid InvalidURL errors on + spaces/quotes. """ - path = _filter_path(resource, filter_expr, select) - resp = graph("GET", path, token, description=description or f"list {resource}") - return resp.get("value", []) if resp else [] # type: ignore[union-attr] + path: str | None = _filter_path(resource, filter_expr, select) + label = description or f"list {resource}" + items: list[dict[str, Any]] = [] + while path is not None: + resp = graph_json("GET", path, token, description=label) + items.extend(resp.get("value", [])) + next_link = resp.get("@odata.nextLink") + path = _relativize_graph_url(next_link) if next_link else None + return items # --------------------------------------------------------------------------- @@ -423,10 +489,21 @@ def _graph_block_scope_ids(rra: list[dict[str, Any]]) -> set[str]: return set() +def _dedupe_preserving_order(values: list[str]) -> list[str]: + """Return values with duplicates removed, preserving first-seen order.""" + seen: set[str] = set() + out: list[str] = [] + for v in values: + if v not in seen: + seen.add(v) + out.append(v) + return out + + def _merge_redirect_uris(existing: list[str], desired: list[str]) -> tuple[list[str], bool]: """Union desired URIs into existing ones (add only). Returns (merged, changed).""" existing_set = set(existing) - to_add = [u for u in desired if u not in existing_set] + to_add = _dedupe_preserving_order([u for u in desired if u not in existing_set]) if not to_add: return existing, False return existing + to_add, True @@ -437,8 +514,20 @@ def _merge_redirect_uris(existing: list[str], desired: list[str]) -> tuple[list[ # --------------------------------------------------------------------------- +class _ArgumentParser(argparse.ArgumentParser): + """ArgumentParser whose usage errors exit 1, not argparse's default 2. + + Exit code 2 is reserved by this tool's contract for 'az missing / not + logged in'; a command-line usage error is exit 1. + """ + + def error(self, message: str) -> NoReturn: + self.print_usage(sys.stderr) + die(message, code=1) + + def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser( + parser = _ArgumentParser( prog=PROG, description=( "Create or reconcile an Entra ID OIDC web application and " @@ -484,7 +573,11 @@ def parse_args() -> argparse.Namespace: default=False, help="Emit a JSON result instead of a human-readable summary", ) - return parser.parse_args() + args = parser.parse_args() + # Dedupe repeated -r values once so neither the create path (400 on + # duplicate redirectUris) nor the merge path can double them up. + args.redirect_uris = _dedupe_preserving_order(args.redirect_uris) + return args # --------------------------------------------------------------------------- @@ -493,8 +586,6 @@ def parse_args() -> argparse.Namespace: def _setup_colour() -> dict[str, str]: - import os - if sys.stdout.isatty() and not os.environ.get("NO_COLOR"): return { "reset": "\033[0m", @@ -509,8 +600,8 @@ def _setup_colour() -> dict[str, str]: def _status_tag(status: str, c: dict[str, str]) -> str: """Coloured status tag for human output.""" - if status == "created": - return f"{c['green']}created{c['reset']}" + if status in ("created", "added"): + return f"{c['green']}{status}{c['reset']}" if status == "updated": return f"{c['yellow']}updated{c['reset']}" if status in ("existed", "unchanged"): @@ -527,7 +618,8 @@ def main() -> None: # noqa: C901 — long but linear args = parse_args() # -- preflight ---------------------------------------------------------- - check_az_login() + # No separate 'az account show' probe: get_graph_token() already fails + # with exit 2 and the same diagnostics if az is missing or logged out. token = get_graph_token(args.tenant) # Tracks whether anything changed this run @@ -539,16 +631,22 @@ def main() -> None: # noqa: C901 — long but linear try: owner_obj = _graph_request( "GET", - f"/users/{urllib.parse.quote(args.owner)}", + f"/users/{urllib.parse.quote(args.owner, safe='')}", token, ) except urllib.error.HTTPError as exc: code, detail = _parse_error(exc) if code == 404: die(f"Owner '{args.owner}' not found in directory.", code=3) - die(f"Owner lookup failed ({code}): {detail}", code=3) - - owner_id: str = owner_obj["id"] # type: ignore[index] + # A real Graph failure (401/403/5xx) is a failed call, not a + # genuinely-absent owner: exit 4, not 3. + die(f"Owner lookup failed ({code}): {detail}", code=4) + except _GRAPH_CALL_ERRORS as exc: + die(f"Owner lookup failed: {exc}", code=4) + + if owner_obj is None: + die("Owner lookup returned an empty response.", code=4) + owner_id: str = owner_obj["id"] info(f"owner object ID: {owner_id}") # -- step 1: app registration (find-or-create + reconcile) ------------- @@ -557,7 +655,7 @@ def main() -> None: # noqa: C901 — long but linear apps = graph_list( "applications", - f"displayName eq '{args.name}'", + f"displayName eq '{_odata_str(args.name)}'", token, select="id,appId,web,requiredResourceAccess,displayName", description="Look up application by displayName", @@ -579,16 +677,17 @@ def main() -> None: # noqa: C901 — long but linear "web": {"redirectUris": args.redirect_uris}, "requiredResourceAccess": desired_rra, } - app = graph( + app = graph_json( "POST", "/applications", token, app_body, description="Create application", ) - app_id: str = app["appId"] # type: ignore[index] - app_obj_id: str = app["id"] # type: ignore[index] + app_id: str = app["appId"] + app_obj_id: str = app["id"] statuses["app"] = "created" + merged_uris = list(args.redirect_uris) any_changed = True info(f"appId: {app_id}") info(f"object ID: {app_obj_id}") @@ -619,7 +718,6 @@ def main() -> None: # noqa: C901 — long but linear f"/applications/{app_obj_id}", token, patch, - expect_no_content=True, description="Reconcile application", ) statuses["app"] = "updated" @@ -631,63 +729,65 @@ def main() -> None: # noqa: C901 — long but linear # Confirm every desired scope ID is present in requiredResourceAccess. # This catches drift between independent runs (e.g. grant set in step 5 # before the manifest was updated) and corrects it with one extra PATCH. - desired_scope_ids = set(DELEGATED_SCOPES.values()) - live_app = graph( - "GET", - f"/applications/{app_obj_id}?$select=requiredResourceAccess", - token, - description="Read back requiredResourceAccess", - ) - live_rra: list[dict[str, Any]] = (live_app or {}).get("requiredResourceAccess", []) - live_scope_ids = _graph_block_scope_ids(live_rra) - still_missing = desired_scope_ids - live_scope_ids - - if still_missing: - missing_names = [name for name, sid in DELEGATED_SCOPES.items() if sid in still_missing] - info( - f"read-back: {len(still_missing)} scope(s) missing from " - f"requiredResourceAccess after PATCH " - f"({', '.join(missing_names)}) — applying corrective PATCH" - ) - corrective_rra, _ = _merge_resource_access(live_rra, desired_rra) - graph( - "PATCH", - f"/applications/{app_obj_id}", - token, - {"requiredResourceAccess": corrective_rra}, - expect_no_content=True, - description="Corrective requiredResourceAccess PATCH", - ) - # Final verification — error loudly if still broken - live_app2 = graph( + # Only needed when we actually touched requiredResourceAccess; if the + # merge found nothing missing, the list query already proved it present. + if rra_changed: + desired_scope_ids = set(DELEGATED_SCOPES.values()) + live_app = graph_json( "GET", f"/applications/{app_obj_id}?$select=requiredResourceAccess", token, - description="Re-read requiredResourceAccess after corrective PATCH", + description="Read back requiredResourceAccess", ) - live_rra2: list[dict[str, Any]] = (live_app2 or {}).get("requiredResourceAccess", []) - still_missing2 = desired_scope_ids - _graph_block_scope_ids(live_rra2) - if still_missing2: - missing_names2 = [name for name, sid in DELEGATED_SCOPES.items() if sid in still_missing2] - die( - "requiredResourceAccess still missing scope(s) after " - f"corrective PATCH: {', '.join(missing_names2)}. " - "Check app manifest in the portal.", - code=4, + live_rra: list[dict[str, Any]] = live_app.get("requiredResourceAccess", []) + live_scope_ids = _graph_block_scope_ids(live_rra) + still_missing = desired_scope_ids - live_scope_ids + + if still_missing: + missing_names = [name for name, sid in DELEGATED_SCOPES.items() if sid in still_missing] + info( + f"read-back: {len(still_missing)} scope(s) missing from " + f"requiredResourceAccess after PATCH " + f"({', '.join(missing_names)}) — applying corrective PATCH" ) - statuses["app"] = "updated" - any_changed = True - info("read-back: corrective PATCH confirmed — all scopes now configured") - else: - info("read-back: all desired scopes present in requiredResourceAccess") - - info(f"app: {statuses['app']}") + corrective_rra, _ = _merge_resource_access(live_rra, desired_rra) + graph( + "PATCH", + f"/applications/{app_obj_id}", + token, + {"requiredResourceAccess": corrective_rra}, + description="Corrective requiredResourceAccess PATCH", + ) + # Final verification — error loudly if still broken + live_app2 = graph_json( + "GET", + f"/applications/{app_obj_id}?$select=requiredResourceAccess", + token, + description="Re-read requiredResourceAccess after corrective PATCH", + ) + live_rra2: list[dict[str, Any]] = live_app2.get("requiredResourceAccess", []) + still_missing2 = desired_scope_ids - _graph_block_scope_ids(live_rra2) + if still_missing2: + missing_names2 = [name for name, sid in DELEGATED_SCOPES.items() if sid in still_missing2] + die( + "requiredResourceAccess still missing scope(s) after " + f"corrective PATCH: {', '.join(missing_names2)}. " + "Check app manifest in the portal.", + code=4, + ) + statuses["app"] = "updated" + any_changed = True + info("read-back: corrective PATCH confirmed — all scopes now configured") + else: + info("read-back: all desired scopes present in requiredResourceAccess") + + info(f"app: {statuses['app']}") # -- step 2: service principal (find-or-create) ------------------------ step("Ensuring service principal\u2026") sps = graph_list( "servicePrincipals", - f"appId eq '{app_id}'", + f"appId eq '{_odata_str(app_id)}'", token, select="id", description="Look up service principal by appId", @@ -698,29 +798,49 @@ def main() -> None: # noqa: C901 — long but linear statuses["sp"] = "existed" info(f"found existing SP — object ID: {sp_obj_id}") else: - sp = graph( + # graph_with_retry() tolerates both transient propagation errors (the + # appId may not be visible yet) and a create-vs-get race (concurrent + # run created the SP first). When swallow_exists fires, we re-fetch + # the object ID below. + sp, sp_existed = graph_with_retry( "POST", "/servicePrincipals", token, {"appId": app_id}, description="Create service principal", + swallow_exists=True, ) - sp_obj_id = sp["id"] # type: ignore[index] - statuses["sp"] = "created" - any_changed = True + if sp is not None and "id" in sp: + sp_obj_id = sp["id"] + statuses["sp"] = "existed" if sp_existed else "created" + else: + # Lost the race (empty/absent body): look the SP up by appId. + existing_sps = graph_list( + "servicePrincipals", + f"appId eq '{_odata_str(app_id)}'", + token, + select="id", + description="Re-look up service principal after race", + ) + if not existing_sps: + die("Service principal creation reported success but none was found.", code=4) + sp_obj_id = existing_sps[0]["id"] + statuses["sp"] = "existed" + if statuses["sp"] == "created": + any_changed = True info(f"SP object ID: {sp_obj_id}") # -- step 3: owners (app + SP) — swallow "already exists" -------------- owner_ref = {"@odata.id": f"{GRAPH_BASE}/directoryObjects/{owner_id}"} step("Ensuring owner on application\u2026") - _, app_owner_existed = graph_or_exists( + _, app_owner_existed = graph_with_retry( "POST", f"/applications/{app_obj_id}/owners/$ref", token, owner_ref, - expect_no_content=True, description="Add application owner", + swallow_exists=True, ) statuses["app_owner"] = "existed" if app_owner_existed else "added" if not app_owner_existed: @@ -728,13 +848,13 @@ def main() -> None: # noqa: C901 — long but linear info(f"app owner: {statuses['app_owner']}") step("Ensuring owner on service principal\u2026") - _, sp_owner_existed = graph_or_exists( + _, sp_owner_existed = graph_with_retry( "POST", f"/servicePrincipals/{sp_obj_id}/owners/$ref", token, owner_ref, - expect_no_content=True, description="Add service principal owner", + swallow_exists=True, ) statuses["sp_owner"] = "existed" if sp_owner_existed else "added" if not sp_owner_existed: @@ -745,7 +865,7 @@ def main() -> None: # noqa: C901 — long but linear step("Looking up Microsoft Graph service principal in tenant\u2026") graph_sps = graph_list( "servicePrincipals", - f"appId eq '{GRAPH_APP_ID}'", + f"appId eq '{_odata_str(GRAPH_APP_ID)}'", token, select="id", description="Get Graph service principal", @@ -762,16 +882,26 @@ def main() -> None: # noqa: C901 — long but linear scope_string = " ".join(DELEGATED_SCOPES.keys()) step(f"Ensuring delegated scope grant ({scope_string})\u2026") + # Filter on consentType eq 'AllPrincipals' so a per-user ('Principal') + # grant is never mistaken for the tenant-wide admin-consent grant. grants = graph_list( "oauth2PermissionGrants", - f"clientId eq '{sp_obj_id}' and resourceId eq '{graph_sp_id}'", + ( + f"clientId eq '{_odata_str(sp_obj_id)}' " + f"and resourceId eq '{_odata_str(graph_sp_id)}' " + "and consentType eq 'AllPrincipals'" + ), token, - select="id,scope", + select="id,scope,consentType", description="Look up delegated permission grant", ) if not grants: - graph_with_retry( + # swallow_exists tolerates the 'already exists' 400 that Graph returns + # when a grant was created concurrently (or by an earlier partial run). + # When the swallow fires, Graph documents replication lag for this list + # endpoint, so we retry the lookup until the grant appears. + grant_resp, grant_existed = graph_with_retry( "POST", "/oauth2PermissionGrants", token, @@ -782,9 +912,69 @@ def main() -> None: # noqa: C901 — long but linear "scope": scope_string, }, description="Grant delegated scopes", + swallow_exists=True, ) - statuses["delegated_grant"] = "created" - any_changed = True + + if grant_resp is not None and not grant_existed: + # Fresh creation: the response body holds the new grant with full scopes. + statuses["delegated_grant"] = "created" + any_changed = True + admin_consent_granted = True + else: + # The swallow fired (grant_existed=True): re-list with retry to find + # the grant, then reconcile scopes just like the existing-grant branch. + info("grant already existed (replication lag or concurrent create) — re-listing with retry…") + grant = None + for retry_attempt in range(1, 7): + refetch_grants = graph_list( + "oauth2PermissionGrants", + ( + f"clientId eq '{_odata_str(sp_obj_id)}' " + f"and resourceId eq '{_odata_str(graph_sp_id)}' " + "and consentType eq 'AllPrincipals'" + ), + token, + select="id,scope,consentType", + description=f"Re-list delegated permission grant (attempt {retry_attempt}/6)", + ) + if refetch_grants: + grant = refetch_grants[0] + break + if retry_attempt < 6: + wait = 10.0 * retry_attempt + warn(f"grant not yet visible (attempt {retry_attempt}/6), retrying in {wait:.0f}s…") + time.sleep(wait) + + if grant is None: + die( + "Grant POST returned 'already exists' but the grant never " + "appeared after 6 retries (replication timeout).", + code=4, + ) + + # Reconcile scopes (same logic as the existing-grant branch below). + existing_scopes = set((grant.get("scope") or "").split()) + desired_scopes = set(DELEGATED_SCOPES.keys()) + missing = desired_scopes - existing_scopes + + if not missing: + statuses["delegated_grant"] = "existed" + admin_consent_granted = True + else: + merged_scopes = " ".join(sorted(existing_scopes | desired_scopes)) + graph( + "PATCH", + f"/oauth2PermissionGrants/{grant['id']}", + token, + {"scope": merged_scopes}, + description="Add missing delegated scopes", + ) + statuses["delegated_grant"] = "updated" + any_changed = True + # Admin consent is granted only if the PATCH succeeded and all + # desired scopes are now present. The PATCH dies on failure + # (graph() has no swallow), so reaching here means success. + admin_consent_granted = True else: grant = grants[0] if len(grants) > 1: @@ -797,6 +987,8 @@ def main() -> None: # noqa: C901 — long but linear if not missing: statuses["delegated_grant"] = "unchanged" + # All desired scopes already present; admin consent is granted. + admin_consent_granted = True else: merged_scopes = " ".join(sorted(existing_scopes | desired_scopes)) graph( @@ -804,11 +996,13 @@ def main() -> None: # noqa: C901 — long but linear f"/oauth2PermissionGrants/{grant['id']}", token, {"scope": merged_scopes}, - expect_no_content=True, description="Add missing delegated scopes", ) statuses["delegated_grant"] = "updated" any_changed = True + # The PATCH succeeded (graph() dies on failure), so all desired + # scopes are now present and admin consent is granted. + admin_consent_granted = True info(f"delegated grant: {statuses['delegated_grant']}") @@ -822,11 +1016,11 @@ def main() -> None: # noqa: C901 — long but linear "upn": args.owner, "objectId": owner_id, }, - "redirectUris": args.redirect_uris, + "redirectUris": merged_uris, "permissions": { "delegatedScopes": list(DELEGATED_SCOPES.keys()), }, - "adminConsentGranted": True, + "adminConsentGranted": admin_consent_granted, "changed": any_changed, "statuses": statuses, } diff --git a/bin/bwclaude b/bin/bwclaude index ffd9281..226e0bf 100755 --- a/bin/bwclaude +++ b/bin/bwclaude @@ -3,27 +3,36 @@ # # Requires: bwrap >= 0.4.0 (RHEL 8) # - 0.4.0: base functionality -# - 0.5.0+: --clearenv (fallback: manual --unsetenv for each host var) # - 0.6.3+: bind over ro-bind (fallback: skip binary masking) # # The sandbox provides filesystem isolation: # - Current directory: read-write -# - Claude home (~/.claude or $CLAUDE_HOME): tmpfs (ephemeral by default) +# - Claude home (~/.claude, or $CLAUDE_CONFIG_DIR when set): tmpfs (ephemeral by default) # - logs/: bind-mounted read-write (persistent) # - projects/: bind-mounted read-write (persistent) # - todos/: bind-mounted read-write (persistent) # - statsig/: bind-mounted read-write (persistent) # - file-history/: bind-mounted read-write (per-file edit history for undo/restore) # - sessions/: bind-mounted read-write (session metadata for --continue) +# - plans/: bind-mounted read-write (plan-mode plan files) # - history.jsonl: bind-mounted read-write (prompt history across sessions) # - .last-cleanup: bind-mounted read-write (throttles Claude's cleanup runs) -# - settings.json: bind-mounted read-write (if it exists; --init-auth creates a stub) +# - settings.json: ephemeral copy (via --file) by default, so a session cannot +# persist hooks/apiKeyHelper/env that would later run unsandboxed on the +# host; bind-mounted read-write only with --init-auth (which creates a stub) # - settings.local.json: bind-mounted read-only (if it exists) # - Use --init-auth to make auth credentials and settings persistent -# - Claude state file (~/.claude.json): bind-mounted read-write (persistent) -# Contains only UI/session state (onboarding flags, feature caches, project history); -# not a credential, safe to persist unconditionally. -# - Claude cache (~/.cache/claude or $CLAUDE_CACHE_HOME): read-write +# - Claude state file (~/.claude.json, or $CLAUDE_CONFIG_DIR/.claude.json when +# CLAUDE_CONFIG_DIR is set): bind-mounted read-write (persistent), shared +# with unsandboxed claude. Besides UI state and onboarding flags it holds +# the OAuth session (if any), per-project trust decisions and personal MCP +# server definitions. KNOWN GAP: a session can add an MCP server or a trust +# decision here that unsandboxed claude will honour later. +# - Authoring directories (skills/, agents/, commands/ under the Claude home): +# read-write, always created on the host, so definitions authored in the +# sandbox persist; skills symlinked in from elsewhere (e.g. a shared skills +# repository) work too +# - Claude cache ($XDG_CACHE_HOME/claude, default ~/.cache/claude): read-write # - Git config + includes: read-only # - Pixi cache: read-write # - Pixi config: read-only @@ -51,7 +60,8 @@ # --exec CMD Run CMD inside the sandbox instead of claude. # Useful for running one-off commands, scripts, or diagnostic tools # inside the fully-configured sandbox environment. -# --init-auth Persist auth credentials to host (run once on personal machines). +# --init-auth Persist auth credentials and settings.json to host (run once on +# personal machines). # --debug Enable verbose debug logging (ANTHROPIC_LOG=debug, NODE_DEBUG, --debug --verbose). # NOTE: shadows claude's own -d/--debug [filter] flag. To pass a debug # filter to claude directly, place it after a non-wrapper argument so @@ -69,6 +79,8 @@ # /etc/sudoers{,.d}. # --rw-path PATH Mount PATH read-write into the sandbox. May be repeated to # accumulate multiple paths. Same blocked-path rules as --ro-path. +# A nested --ro-path/--rw-path overrides the one it sits under, +# so `--rw-path DIR --ro-path DIR/sub` protects DIR/sub. set -euo pipefail @@ -165,8 +177,12 @@ Wrapper options (consumed before claude sees the command line): Use this to run scripts, diagnostic tools, or any command with the same filesystem isolation and environment as claude itself. Example: bwclaude --exec bash - bwclaude --exec 'env | sort' + bwclaude --exec bash -c 'env | sort' + CMD itself is split on whitespace; pass arguments that need + quoting as separate words after it, as above. --init-auth Persist auth credentials to host (run once on personal machines). + Also makes settings.json writable and persistent; without it + the sandbox sees a throwaway copy of settings.json. --debug Enable verbose debug logging (sets ANTHROPIC_LOG=debug, NODE_DEBUG=http,https,tls, and passes --debug --verbose to claude). NOTE: shadows claude's own -d/--debug [filter] flag. To pass @@ -188,6 +204,9 @@ Wrapper options (consumed before claude sees the command line): Symlinks are resolved before the safety check is applied. --rw-path PATH Mount PATH (file or directory) read-write into the sandbox. Same blocked-path rules as --ro-path. May be repeated. + --ro-path/--rw-path may nest: a nested path overrides the one + it sits under, so `--rw-path DIR --ro-path DIR/sub` keeps DIR + writable but DIR/sub read-only. --github-tokens Forward any GH_TOKEN_* environment variables into the sandbox so the agent can authenticate the gh(1) CLI. Off by default. When set, use per-command token selection: @@ -205,32 +224,34 @@ Authentication: HELP_EOF # Delegate to claude --help inside the sandbox so the user sees the full # claude option set in the same invocation. - exec "$0" --exec "${_TOOL_BIN} --help" + bwrap_show_tool_help claude } resolve_claude_paths() { - # Claude home: config, credentials, projects, logs, todos. - # Override: CLAUDE_HOME env var. - CLAUDE_HOME_DIR="${CLAUDE_HOME:-${_HOME}/.claude}" - - # Claude cache: follows XDG on Linux. - # Override: CLAUDE_CACHE_HOME env var. - if [[ -n "${CLAUDE_CACHE_HOME:-}" ]]; then - CLAUDE_CACHE_DIR="${CLAUDE_CACHE_HOME}" + # Claude home: config, credentials, projects, logs, todos. The Claude CLI + # reads CLAUDE_CONFIG_DIR (default ~/.claude); the mounts must be keyed on + # the same directory or persistence and credentials silently break. + # The state file (onboarding flags, project history, ...) is + # ~/.claude.json by default and $CLAUDE_CONFIG_DIR/.claude.json when + # CLAUDE_CONFIG_DIR is set. It is shared with unsandboxed claude. + if [[ -n "${CLAUDE_CONFIG_DIR:-}" ]]; then + _bwrap_canonicalize_safe "${CLAUDE_CONFIG_DIR}" "CLAUDE_CONFIG_DIR" root + CLAUDE_HOME_DIR="${_BWRAP_CANON}" + CLAUDE_STATE_FILE="${CLAUDE_HOME_DIR}/.claude.json" else - CLAUDE_CACHE_DIR="${XDG_CACHE_HOME}/claude" + CLAUDE_HOME_DIR="${_HOME}/.claude" + _check_dir_safe "${CLAUDE_HOME_DIR}" "CLAUDE_CONFIG_DIR" + CLAUDE_STATE_FILE="${_HOME}/.claude.json" fi + # The state file is bound read-write: refuse one that resolves into a + # blocked location (e.g. a ~/.claude.json symlink into ~/.ssh). + _bwrap_canonicalize_safe "${CLAUDE_STATE_FILE}" "Claude state file" - CLAUDE_CREDENTIALS_FILE="${CLAUDE_HOME_DIR}/.credentials.json" - - # State file at $HOME/.claude.json (not inside .claude/) tracks - # hasCompletedOnboarding, numStartups, userID, project history. - CLAUDE_STATE_FILE="${_HOME}/.claude.json" + # Claude cache: the CLI follows XDG_CACHE_HOME. + CLAUDE_CACHE_DIR="${XDG_CACHE_HOME}/claude" + _check_dir_safe "${CLAUDE_CACHE_DIR}" "XDG_CACHE_HOME (claude cache)" - # Validate the user-controllable root directories once here so that all - # sub-path mounts derived from them are implicitly covered. - _check_dir_safe "${CLAUDE_HOME_DIR}" "CLAUDE_HOME" - _check_dir_safe "${CLAUDE_CACHE_DIR}" "CLAUDE_CACHE_HOME" + CLAUDE_CREDENTIALS_FILE="${CLAUDE_HOME_DIR}/.credentials.json" } # Ensure writable directories exist on host. @@ -265,7 +286,7 @@ build_claude_home_mount() { # Persistent read-write directories local _subdir - for _subdir in logs projects todos statsig file-history sessions; do + for _subdir in logs projects todos statsig file-history plans sessions; do BWRAP_ARGS+=(--bind "${CLAUDE_HOME_DIR}/${_subdir}" "${CLAUDE_HOME_DIR}/${_subdir}") done @@ -277,16 +298,29 @@ build_claude_home_mount() { local _settings_path="${CLAUDE_HOME_DIR}/settings.json" - # With --init-auth, create a stub settings.json on the host if none exists, - # so it can be bind-mounted read-write and persist across sessions. - if [[ "${INIT_AUTH}" -eq 1 ]] && [[ ! -e "${_settings_path}" ]]; then - ( umask 022 && printf '{}' > "${_settings_path}" ) - echo "Created ${_settings_path} — settings will persist after this session." >&2 - fi - - # settings.json: read-write if it exists (claude may update it during the session) - if [[ -e "${_settings_path}" ]]; then - BWRAP_ARGS+=(--bind "${_settings_path}" "${_settings_path}") + # settings.json can define hooks, apiKeyHelper and env that run + # UNSANDBOXED the next time claude is used on the host, so a sandboxed + # session must not be able to persist it. By default it is provided as + # an ephemeral copy via --file (like .credentials.json); only --init-auth + # binds it read-write, creating a stub when it does not exist yet. + if [[ "${INIT_AUTH}" -eq 1 ]]; then + # File creation is skipped on --dry-run to keep that mode side-effect-free. + if [[ ! -e "${_settings_path}" ]] && [[ "${DRY_RUN}" -eq 0 ]]; then + ( umask 077 && printf '{}' > "${_settings_path}" ) + echo "Created ${_settings_path} — settings will persist after this session." >&2 + fi + if _persist_bind_wanted "${_settings_path}"; then + BWRAP_ARGS+=(--bind "${_settings_path}" "${_settings_path}") + fi + elif [[ -f "${_settings_path}" ]]; then + if [[ "${DRY_RUN}" -eq 1 ]]; then + # A real fd number could not be reproduced by copy-paste. + BWRAP_ARGS+=(--file "" "${_settings_path}") + else + local _settings_fd + exec {_settings_fd}< "${_settings_path}" + BWRAP_ARGS+=(--file "${_settings_fd}" "${_settings_path}") + fi fi # Persistent read-only config file (if it exists) @@ -296,28 +330,53 @@ build_claude_home_mount() { fi # Credentials: ephemeral by default, persistent with --init-auth. + # File creation is skipped on --dry-run to keep that mode side-effect-free. if [[ "${INIT_AUTH}" -eq 1 ]]; then - if [[ ! -f "${CLAUDE_CREDENTIALS_FILE}" ]]; then + if [[ ! -f "${CLAUDE_CREDENTIALS_FILE}" ]] && [[ "${DRY_RUN}" -eq 0 ]]; then ( umask 077 && printf '{}' > "${CLAUDE_CREDENTIALS_FILE}" ) echo "Created ${CLAUDE_CREDENTIALS_FILE} — credentials will persist after this session." >&2 fi - BWRAP_ARGS+=(--bind "${CLAUDE_CREDENTIALS_FILE}" "${CLAUDE_CREDENTIALS_FILE}") + if _persist_bind_wanted "${CLAUDE_CREDENTIALS_FILE}"; then + BWRAP_ARGS+=(--bind "${CLAUDE_CREDENTIALS_FILE}" "${CLAUDE_CREDENTIALS_FILE}") + fi elif [[ -f "${CLAUDE_CREDENTIALS_FILE}" ]]; then # Normal mode: copy credentials into tmpfs via --file so host auth is # available but changes don't persist. - local _fd - exec {_fd}< "${CLAUDE_CREDENTIALS_FILE}" - BWRAP_ARGS+=(--file "${_fd}" "${CLAUDE_CREDENTIALS_FILE}") + if [[ "${DRY_RUN}" -eq 1 ]]; then + BWRAP_ARGS+=(--file "" "${CLAUDE_CREDENTIALS_FILE}") + else + local _fd + exec {_fd}< "${CLAUDE_CREDENTIALS_FILE}" + BWRAP_ARGS+=(--file "${_fd}" "${CLAUDE_CREDENTIALS_FILE}") + fi fi - # State file (~/.claude.json): bind RW unconditionally. Contains only - # UI/session state (onboarding flags, feature caches, project history) — - # not a credential. Without this, $HOME is tmpfs and claude triggers - # the onboarding flow on every invocation. - if [[ ! -f "${CLAUDE_STATE_FILE}" ]]; then + # State file (~/.claude.json, or $CLAUDE_CONFIG_DIR/.claude.json): bind RW + # unconditionally. Without it, $HOME is tmpfs and claude runs the + # onboarding flow on every invocation. The file is not only UI state: it + # also holds the OAuth session (if any), per-project trust decisions and + # personal MCP server definitions, which unsandboxed claude reads too. + # Writing those from inside the sandbox is a known, not yet closed, gap. + # File creation is skipped on --dry-run to keep that mode side-effect-free. + if [[ ! -f "${CLAUDE_STATE_FILE}" ]] && [[ "${DRY_RUN}" -eq 0 ]]; then ( umask 077 && printf '{}' > "${CLAUDE_STATE_FILE}" ) fi - BWRAP_ARGS+=(--bind "${CLAUDE_STATE_FILE}" "${CLAUDE_STATE_FILE}") + # A real run always creates and binds the state file, so emit the bind + # under --dry-run too (where the stub above is not created). + if [[ -f "${CLAUDE_STATE_FILE}" ]] || [[ "${DRY_RUN}" -eq 1 ]]; then + BWRAP_ARGS+=(--bind "${CLAUDE_STATE_FILE}" "${CLAUDE_STATE_FILE}") + fi +} + +# ── Claude authoring directories (read-write, persistent) ──────── +# skills/, agents/ and commands/ hold user-authored markdown definitions. +# They are created on the host when missing and mounted read-write so the +# agent can create and edit them from inside the sandbox. Must run AFTER +# build_claude_home_mount (the home is a tmpfs that would hide them). +build_claude_authoring_mounts() { + build_authoring_dir_mounts skill "${CLAUDE_HOME_DIR}/skills" + build_authoring_dir_mounts agent "${CLAUDE_HOME_DIR}/agents" + build_authoring_dir_mounts command "${CLAUDE_HOME_DIR}/commands" } # ── Claude cache (read-write) ──────────────────────────────────── @@ -357,16 +416,27 @@ build_claude_env() { # ignores ANTHROPIC_BASE_URL / ANTHROPIC_API_KEY, so we no longer set # those defaults. - # Foundry mode requires an API key. Fail fast with a clear message - # rather than letting Claude Code fall through to the Azure SDK - # credential chain (az login, managed identity, etc.) and emit a - # wall of CredentialUnavailableError output. - if [[ -z "${ANTHROPIC_FOUNDRY_API_KEY:-}" ]] && [[ -z "${AIFAPIM_API_KEY:-}" ]]; then - echo "Error: bwclaude (foundry mode) requires an API key." >&2 - echo "Export one of:" >&2 - echo " ANTHROPIC_FOUNDRY_API_KEY= # direct foundry key" >&2 - echo " AIFAPIM_API_KEY= # NSLS-II AIFAPIM key (preferred)" >&2 - exit 1 + # Foundry mode requires an API key and a gateway. Fail fast with a clear + # message rather than letting Claude Code fall through to the Azure SDK + # credential chain (az login, managed identity, etc.) and emit a wall of + # CredentialUnavailableError output. Only checked when claude itself is + # launched: --dry-run inspects the sandbox and --exec runs another + # command, neither needs credentials. + if [[ "${DRY_RUN}" -eq 0 ]] && [[ -z "${TEST_CMD}" ]]; then + if [[ -z "${ANTHROPIC_FOUNDRY_API_KEY:-}" ]] && [[ -z "${AIFAPIM_API_KEY:-}" ]]; then + echo "Error: bwclaude (foundry mode) requires an API key." >&2 + echo "Export one of:" >&2 + echo " ANTHROPIC_FOUNDRY_API_KEY= # direct foundry key" >&2 + echo " AIFAPIM_API_KEY= # NSLS-II AIFAPIM key (preferred)" >&2 + exit 1 + fi + if [[ -z "${ANTHROPIC_FOUNDRY_BASE_URL:-}" ]] && [[ -z "${AIFAPIM_HOST:-}" ]]; then + echo "Error: bwclaude requires AIFAPIM_HOST (or ANTHROPIC_FOUNDRY_BASE_URL) to be set." >&2 + echo "Export one of:" >&2 + echo " AIFAPIM_HOST= # NSLS-II AIFAPIM gateway hostname" >&2 + echo " ANTHROPIC_FOUNDRY_BASE_URL=https://... # full Foundry base URL override" >&2 + exit 1 + fi fi # Foundry API key: prefer host's ANTHROPIC_FOUNDRY_API_KEY; fall back to @@ -374,46 +444,33 @@ build_claude_env() { if [[ -n "${ANTHROPIC_FOUNDRY_API_KEY:-}" ]]; then pass_through_if_set ANTHROPIC_FOUNDRY_API_KEY elif [[ -n "${AIFAPIM_API_KEY:-}" ]]; then - BWRAP_ARGS+=(--setenv ANTHROPIC_FOUNDRY_API_KEY "${AIFAPIM_API_KEY}") + # Through SANDBOX_ENV, not --setenv: keeps the key off bwrap's argv. + SANDBOX_ENV+=("ANTHROPIC_FOUNDRY_API_KEY=${AIFAPIM_API_KEY}") fi # Foundry base URL: prefer explicit ANTHROPIC_FOUNDRY_BASE_URL override; - # otherwise derive from AIFAPIM_HOST (required). + # otherwise derive from AIFAPIM_HOST. if [[ -n "${ANTHROPIC_FOUNDRY_BASE_URL:-}" ]]; then pass_through_if_set ANTHROPIC_FOUNDRY_BASE_URL elif [[ -n "${AIFAPIM_HOST:-}" ]]; then BWRAP_ARGS+=(--setenv ANTHROPIC_FOUNDRY_BASE_URL "https://${AIFAPIM_HOST}/anthropic") - else - echo "Error: bwclaude requires AIFAPIM_HOST (or ANTHROPIC_FOUNDRY_BASE_URL) to be set." >&2 - echo "Export one of:" >&2 - echo " AIFAPIM_HOST= # NSLS-II AIFAPIM gateway hostname" >&2 - echo " ANTHROPIC_FOUNDRY_BASE_URL=https://... # full Foundry base URL override" >&2 - exit 1 fi # Forward any host CLAUDE_* env vars (incl. user overrides for verbosity, etc.). - local _key - while IFS= read -r _key; do - if [[ "${_key}" == CLAUDE_* ]]; then - BWRAP_ARGS+=(--setenv "${_key}" "${!_key}") - fi - done < <(compgen -e) + forward_env_by_prefix CLAUDE_ + + # A host CLAUDE_CONFIG_DIR is forwarded above as written; point it at the + # canonical directory the mounts were built on (--setenv: last one wins). + # When the host does not set it, neither does the sandbox, so claude uses + # ~/.claude and ~/.claude.json exactly as outside the sandbox. + if [[ -n "${CLAUDE_CONFIG_DIR:-}" ]]; then + BWRAP_ARGS+=(--setenv CLAUDE_CONFIG_DIR "${CLAUDE_HOME_DIR}") + fi - # GitHub CLI token passthrough (opt-in via --github-tokens) - # In --dry-run mode the value is replaced with REDACTED so that tokens - # are not printed to the terminal in the bwrap command output. + # GitHub CLI token passthrough (opt-in via --github-tokens). In + # --dry-run, print_dry_run redacts the values (the names match *TOKEN*). if [[ "${FORWARD_GH_TOKENS}" -eq 1 ]]; then - local _gh_val - while IFS= read -r _key; do - if [[ "${_key}" == GH_TOKEN_* ]]; then - if [[ "${DRY_RUN}" -eq 1 ]]; then - _gh_val="REDACTED" - else - _gh_val="${!_key}" - fi - BWRAP_ARGS+=(--setenv "${_key}" "${_gh_val}") - fi - done < <(compgen -e) + forward_env_by_prefix GH_TOKEN_ fi # Enforce after the loop so host env cannot override these. @@ -435,11 +492,10 @@ build_claude_env() { if [[ "${DEBUG_MODE}" -eq 1 ]]; then BWRAP_ARGS+=(--setenv ANTHROPIC_LOG debug) BWRAP_ARGS+=(--setenv NODE_DEBUG "http,https,tls") - # Prepend --debug --verbose to the args passed to claude. - if [[ "${#TOOL_ARGS[@]}" -gt 0 ]]; then - TOOL_ARGS=(--debug --verbose "${TOOL_ARGS[@]}") - else - TOOL_ARGS=(--debug --verbose) + # Prepend --debug --verbose to the args passed to claude (not to an + # --exec command, which is not claude). + if [[ -z "${TEST_CMD}" ]]; then + TOOL_ARGS=(--debug --verbose ${TOOL_ARGS[@]+"${TOOL_ARGS[@]}"}) fi fi } @@ -460,25 +516,28 @@ build_claude_bin_mount() { main() { bwrap_check_account parse_wrapper_args "$@" - bwrap_resolve_tool claude if [[ "${SHOW_HELP}" -eq 1 ]]; then show_help - # show_help does exec, never returns + # show_help never returns fi + bwrap_resolve_tool claude + detect_bwrap_capabilities detect_kernel_capabilities resolve_common_paths resolve_claude_paths detect_shell - ensure_host_dirs + # Skip host-state mutations on dry-run so --dry-run remains side-effect-free. + if [[ "${DRY_RUN}" -eq 0 ]]; then + ensure_host_dirs + fi parse_all_git_includes init_sandbox_path # Build bwrap argument list build_base_sandbox - build_binary_masks build_proc_dev_tmp build_sys_mounts build_etc_mounts @@ -486,6 +545,7 @@ main() { build_home_tmpfs build_workdir_mount build_claude_home_mount + build_claude_authoring_mounts build_claude_cache_mount build_git_mounts build_pixi_mounts @@ -501,6 +561,10 @@ main() { build_env_vars build_claude_env + # Mask dangerous binaries as the very last step: a mask is hidden by any + # later bind that covers it (e.g. --ro-path /usr or /usr/bin). + build_binary_masks + # Launch if [[ "${DRY_RUN}" -eq 1 ]]; then print_dry_run diff --git a/bin/bwcodex b/bin/bwcodex index dbc1a7e..c217ca8 100755 --- a/bin/bwcodex +++ b/bin/bwcodex @@ -3,7 +3,6 @@ # # Requires: bwrap >= 0.4.0 (RHEL 8) # - 0.4.0: base functionality -# - 0.5.0+: --clearenv (fallback: manual --unsetenv for each host var) # - 0.6.3+: bind over ro-bind (fallback: skip binary masking) # # Two approved auth paths are supported (configured in ~/.codex/config.toml): @@ -25,25 +24,30 @@ # - sessions/: bind-mounted read-write (for `codex resume` / `codex fork`) # - log/: bind-mounted read-write (RUST_LOG / opt-in plaintext logs) # - history.jsonl: bind-mounted read-write (prompt history) -# - config.toml: by default, copied to a per-session staging file and -# bind-mounted RW so Codex can persist project-trust grants for the -# session without touching the host file (the staging copy is removed -# on script exit). With --persist-config, the host config.toml is +# - config.toml: by default, opened as a file descriptor and passed to +# bwrap via --file so Codex can persist project-trust grants for the +# session without touching the host file (the temp copy is unlinked +# immediately after opening; no cleanup needed on exit). +# With --persist-config, the host config.toml is # bind-mounted RW so writes persist across sessions at the cost of # weaker isolation. Either the user file or the system file # (/etc/codex/config.toml) is sufficient for fail-fast. +# - AGENTS.md: bind-mounted read-only (if it exists) +# - packages/: tmpfs; managed daemon package copied from the CLI on each run +# - app-server-daemon/settings.json: ephemeral, disables daemon auto-update +# - auth.json: bind-mounted read-write only if it already exists on host; +# --init-auth creates a {} stub for first-time ChatGPT-login setup +# - Everything else (tmp/, state.toml, features/, ...): tmpfs, discarded on exit +# - Authoring directories (~/.codex/skills, ~/.codex/prompts): read-write, +# always created on the host, so definitions authored in the sandbox +# persist; ~/.agents/skills: read-write (if present). Skills symlinked in +# from elsewhere (e.g. a shared skills repository) work too # - /etc/codex/config.toml: system-wide provider/defaults config (if # present); bind-mounted read-only. Codex layers user config on # top of this file. # - /etc/codex/requirements.toml: admin-enforced restrictions (if # present); bind-mounted read-only. Users cannot loosen these. -# - AGENTS.md: bind-mounted read-only (if it exists) -# - skills/: bind-mounted read-only (if it exists) -# - packages/: bind-mounted read-only (if it exists, installer cache) -# - auth.json: bind-mounted read-write only if it already exists on host; -# --init-auth creates a {} stub for first-time ChatGPT-login setup -# - Everything else (tmp/, state.toml, features/, ...): tmpfs, discarded on exit -# - Codex CLI binary + npm prefix: read-only (handled by build_dynamic_tool_mounts) +# - Codex CLI package (including metadata and resources): read-only # - Git config + includes: read-only # - Pixi cache: read-write # - Pixi config: read-only @@ -92,6 +96,8 @@ # /etc/sudoers{,.d}. # --rw-path PATH Mount PATH read-write into the sandbox. May be repeated to # accumulate multiple paths. Same blocked-path rules as --ro-path. +# A nested --ro-path/--rw-path overrides the one it sits under, +# so `--rw-path DIR --ro-path DIR/sub` protects DIR/sub. set -euo pipefail @@ -193,7 +199,9 @@ Wrapper options (consumed before codex sees the command line): Use this to run scripts, diagnostic tools, or any command with the same filesystem isolation and environment as codex itself. Example: bwcodex --exec bash - bwcodex --exec 'env | sort' + bwcodex --exec bash -c 'env | sort' + CMD itself is split on whitespace; pass arguments that need + quoting as separate words after it, as above. --init-auth Create ~/.codex/auth.json on the host so that ChatGPT-login OAuth tokens persist across sessions. Only meaningful for the BNL ITD ChatGPT subscription auth path; has no effect for @@ -224,6 +232,9 @@ Wrapper options (consumed before codex sees the command line): Symlinks are resolved before the safety check is applied. --rw-path PATH Mount PATH (file or directory) read-write into the sandbox. Same blocked-path rules as --ro-path. May be repeated. + --ro-path/--rw-path may nest: a nested path overrides the one + it sits under, so `--rw-path DIR --ro-path DIR/sub` keeps DIR + writable but DIR/sub read-only. --github-tokens Forward any GH_TOKEN_* environment variables into the sandbox so the agent can authenticate the gh(1) CLI. Off by default. When set, use per-command token selection: @@ -243,7 +254,7 @@ Authentication: HELP_EOF # Delegate to codex --help inside the sandbox so the user sees the full # codex option set in the same invocation. - exec "$0" --exec "${_TOOL_BIN} --help" + bwrap_show_tool_help codex } resolve_codex_paths() { @@ -257,8 +268,9 @@ resolve_codex_paths() { CODEX_SESSIONS_DIR="${CODEX_HOME_DIR}/sessions" CODEX_LOG_DIR="${CODEX_HOME_DIR}/log" CODEX_SKILLS_DIR="${CODEX_HOME_DIR}/skills" + CODEX_PROMPTS_DIR="${CODEX_HOME_DIR}/prompts" CODEX_HISTORY_FILE="${CODEX_HOME_DIR}/history.jsonl" - CODEX_PACKAGES_DIR="${CODEX_HOME_DIR}/packages" + CODEX_DAEMON_SETTINGS_FILE="${CODEX_HOME_DIR}/app-server-daemon/settings.json" # Validate the user-controllable root directory once here so that all # sub-path mounts derived from it are implicitly covered. @@ -292,6 +304,18 @@ check_codex_config() { fi } +# ── Codex authoring directories (read-write, persistent) ────────── +# skills/ and prompts/ hold user-authored markdown definitions. They are +# created on the host when missing and mounted read-write so the agent can +# create and edit them from inside the sandbox. ~/.agents/skills is an +# additional skills location, mounted only if it already exists. Must run +# AFTER build_codex_home_mount (the home is a tmpfs that would hide them). +build_codex_authoring_mounts() { + build_authoring_dir_mounts skill "${CODEX_SKILLS_DIR}" + build_authoring_dir_mounts prompt "${CODEX_PROMPTS_DIR}" + build_personal_authoring_mounts skill "${_HOME}/.agents/skills" +} + # ── Managed Codex skills (read-only) ────────────────────────────── # Resolve the fixed administrator-managed alias before binding it at the # location Codex discovers. This is deliberately not a --ro-path mount. @@ -341,22 +365,36 @@ build_codex_home_mount() { fi # User config.toml mount strategy (see file header): - # - Default: stage a writable copy in /tmp, bind RW into sandbox. - # Codex can persist project trust / other mutations for the session, - # but the host ~/.codex/config.toml is never touched. The staging - # file is cleaned up on script exit via the EXIT trap. + # - Default: open a temporary copy of config.toml as an fd and pass it + # to bwrap via --file. bwrap creates a plain (writable) file in the + # sandbox tmpfs from the fd content, so Codex can persist project-trust + # grants for the session without touching the host file. The temp + # file is unlinked immediately after the fd is opened, so no cleanup + # is needed on exit — the fd holds the only reference and closes when + # bwrap exec's into the tool. # - --persist-config: bind host config.toml RW so writes persist. # If the user file does not exist (managed host using only system # config), skip the user mount entirely. if [[ -f "${CODEX_CONFIG_FILE}" ]]; then if [[ "${PERSIST_CONFIG}" -eq 1 ]]; then BWRAP_ARGS+=(--bind "${CODEX_CONFIG_FILE}" "${CODEX_CONFIG_FILE}") + elif [[ "${DRY_RUN}" -eq 1 ]]; then + # --dry-run stages nothing on the host, and a real fd number could + # not be reproduced by copy-paste: print a placeholder instead. + BWRAP_ARGS+=(--file "" "${CODEX_CONFIG_FILE}") else - _STAGED_CONFIG="$(mktemp --tmpdir bwcodex-config.XXXXXX.toml)" - cp -p "${CODEX_CONFIG_FILE}" "${_STAGED_CONFIG}" - chmod u+w "${_STAGED_CONFIG}" - trap 'rm -f "${_STAGED_CONFIG}"' EXIT INT TERM - BWRAP_ARGS+=(--bind "${_STAGED_CONFIG}" "${CODEX_CONFIG_FILE}") + local _config_fd _staged_config + _staged_config="$(mktemp --tmpdir bwcodex-config.XXXXXX.toml)" + # Plain cp (not cp -p) keeps mktemp's 0600 mode; -p would apply + # the source's mode (typically 0644), leaving a world-readable + # copy in the shared temp directory until the unlink below. + cp "${CODEX_CONFIG_FILE}" "${_staged_config}" + # Open the copy as an fd then immediately unlink it. The kernel + # keeps the data alive via the open fd; the filename disappears + # from /tmp so there is nothing to clean up on exit or signal. + exec {_config_fd}< "${_staged_config}" + rm -f "${_staged_config}" + BWRAP_ARGS+=(--file "${_config_fd}" "${CODEX_CONFIG_FILE}") fi fi @@ -364,25 +402,60 @@ build_codex_home_mount() { BWRAP_ARGS+=(--ro-bind "${CODEX_AGENTS_FILE}" "${CODEX_AGENTS_FILE}") fi - if [[ -d "${CODEX_SKILLS_DIR}" ]]; then - BWRAP_ARGS+=(--ro-bind "${CODEX_SKILLS_DIR}" "${CODEX_SKILLS_DIR}") - fi - - if [[ -d "${CODEX_PACKAGES_DIR}" ]]; then - BWRAP_ARGS+=(--ro-bind "${CODEX_PACKAGES_DIR}" "${CODEX_PACKAGES_DIR}") + # Keep the managed daemon package in tmpfs. The complete CLI package + # mounted by build_codex_bin_mount seeds it on each launch. Disable the + # daemon's updater before Codex starts so it does not fetch a newer copy. + BWRAP_ARGS+=(--perms 0700 --dir "${CODEX_HOME_DIR}/app-server-daemon") + if [[ "${DRY_RUN}" -eq 1 ]]; then + BWRAP_ARGS+=(--perms 0600 --file "" "${CODEX_DAEMON_SETTINGS_FILE}") + else + local _settings_fd _staged_settings + _staged_settings="$(mktemp --tmpdir bwcodex-daemon-settings.XXXXXX.json)" + printf '{"updater":{"autoUpdateEnabled":false}}\n' > "${_staged_settings}" + exec {_settings_fd}< "${_staged_settings}" + rm -f "${_staged_settings}" + BWRAP_ARGS+=(--perms 0600 --file "${_settings_fd}" "${CODEX_DAEMON_SETTINGS_FILE}") fi # auth.json: only persist if it already exists on the host. # Use --init-auth on first run to bootstrap persistent ChatGPT-login auth. + # File creation is skipped on --dry-run to keep that mode side-effect-free. if [[ "${INIT_AUTH}" -eq 1 ]] && [[ ! -f "${CODEX_AUTH_FILE}" ]]; then - ( umask 077 && printf '{}\n' > "${CODEX_AUTH_FILE}" ) - echo "Created ${CODEX_AUTH_FILE} — auth tokens will persist after this session." >&2 + if [[ "${DRY_RUN}" -eq 0 ]]; then + ( umask 077 && printf '{}\n' > "${CODEX_AUTH_FILE}" ) + echo "Created ${CODEX_AUTH_FILE} — auth tokens will persist after this session." >&2 + fi fi - if [[ -f "${CODEX_AUTH_FILE}" ]]; then + if _persist_bind_wanted "${CODEX_AUTH_FILE}"; then BWRAP_ARGS+=(--bind "${CODEX_AUTH_FILE}" "${CODEX_AUTH_FILE}") fi } +# ── SSL_CERT_FILE (read-only) ──────────────────────────────────── +# Codex honours SSL_CERT_FILE, but forwarding only the variable is not +# enough: a bundle under $HOME (or anywhere else not mounted) is absent in +# the sandbox and TLS fails. Mount the canonical file read-only and point +# SSL_CERT_FILE at it; refuse with a warning (and do not forward) when it +# does not resolve to a readable file or resolves into a blocked location. +build_ssl_cert_file_mount() { + [[ -n "${SSL_CERT_FILE:-}" ]] || return 0 + local _canon + _canon="$(readlink -f -- "${SSL_CERT_FILE}" 2> /dev/null || true)" + if [[ -z "${_canon}" || ! -f "${_canon}" || ! -r "${_canon}" ]]; then + echo "Warning: SSL_CERT_FILE=${SSL_CERT_FILE} is not a readable file; not forwarding it." >&2 + return 0 + fi + if _bwrap_path_blocked "${_canon}"; then + echo "Warning: SSL_CERT_FILE=${SSL_CERT_FILE} resolves into a blocked location; not forwarding it." >&2 + return 0 + fi + # Bound even when a parent tree is already mounted (harmless there), so + # a file under a sandbox tmpfs such as /tmp is still provided. + _emit_home_intermediate_dirs "${_canon%/*}" inclusive + BWRAP_ARGS+=(--ro-bind "${_canon}" "${_canon}") + BWRAP_ARGS+=(--setenv SSL_CERT_FILE "${_canon}") +} + # ── Codex env vars ─────────────────────────────────────────────── # Pass through AIFAPIM_* (Hermes routing) and the documented Codex # env vars; default OPENAI_API_KEY to "dummy" if unset so the Codex @@ -403,54 +476,41 @@ build_codex_env() { pass_through_if_set OPENAI_API_KEY fi - # Documented Codex env vars - pass_through_if_set CODEX_HOME - pass_through_if_set CODEX_SQLITE_HOME - pass_through_if_set CODEX_API_KEY - pass_through_if_set CODEX_ACCESS_TOKEN - pass_through_if_set CODEX_CA_CERTIFICATE - pass_through_if_set SSL_CERT_FILE + # Non-CODEX_* vars that Codex consults. + build_ssl_cert_file_mount pass_through_if_set RUST_LOG - # Generic CODEX_* sweep so user-defined overrides flow through. - local _key - while IFS= read -r _key; do - if [[ "${_key}" == CODEX_* ]]; then - BWRAP_ARGS+=(--setenv "${_key}" "${!_key}") - fi - done < <(compgen -e) + # Generic CODEX_* sweep — covers CODEX_HOME, CODEX_SQLITE_HOME, + # CODEX_API_KEY, CODEX_ACCESS_TOKEN, CODEX_CA_CERTIFICATE, and any + # user-defined CODEX_* overrides in a single pass. + forward_env_by_prefix CODEX_ # --debug wrapper flag: force verbose logging regardless of host env. if [[ "${DEBUG_MODE}" -eq 1 ]]; then BWRAP_ARGS+=(--setenv RUST_LOG "debug") fi - # GitHub CLI token passthrough (opt-in via --github-tokens) - # In --dry-run mode the value is replaced with REDACTED so that tokens - # are not printed to the terminal in the bwrap command output. + # GitHub CLI token passthrough (opt-in via --github-tokens). In + # --dry-run, print_dry_run redacts the values (the names match *TOKEN*). if [[ "${FORWARD_GH_TOKENS}" -eq 1 ]]; then - local _gh_val - while IFS= read -r _key; do - if [[ "${_key}" == GH_TOKEN_* ]]; then - if [[ "${DRY_RUN}" -eq 1 ]]; then - _gh_val="REDACTED" - else - _gh_val="${!_key}" - fi - BWRAP_ARGS+=(--setenv "${_key}" "${_gh_val}") - fi - done < <(compgen -e) + forward_env_by_prefix GH_TOKEN_ fi } -# ── Codex install dir (stub) ───────────────────────────────────── -# Codex CLI is installed via npm at /usr/local/lib/node_modules/@openai/codex, -# which build_dynamic_tool_mounts already covers via the npm-prefix mount. -# This stub is kept for symmetry with bwclaude/bwopencode and as a future -# hook for non-npm Codex installs (e.g. the standalone installer's -# /opt/codex layout). +# ── Complete Codex CLI package ────────────────────────────────── +# A packaged CLI needs codex-package.json and its sibling resource dirs to +# seed a managed app-server daemon. The dynamic tool mount only sees the +# binary directory for a symlink such as /usr/bin/codex -> +# /opt/openai-codex/bin/codex. +# Mount the entire package when its metadata is present; npm installations +# are already covered by the dynamic npm-prefix mount. build_codex_bin_mount() { - : + local _real_bin _package_dir + _real_bin="$(readlink -f -- "${_TOOL_BIN}")" + _package_dir="$(dirname "$(dirname "${_real_bin}")")" + if [[ -f "${_package_dir}/codex-package.json" ]]; then + _mount_ro_dir_if_needed "${_package_dir}" + fi } # ═══════════════════════════════════════════════════════════════════ @@ -460,26 +520,29 @@ build_codex_bin_mount() { main() { bwrap_check_account parse_wrapper_args "$@" - bwrap_resolve_tool codex if [[ "${SHOW_HELP}" -eq 1 ]]; then show_help - # show_help does exec, never returns + # show_help never returns fi + bwrap_resolve_tool codex + detect_bwrap_capabilities detect_kernel_capabilities resolve_common_paths resolve_codex_paths detect_shell - ensure_host_dirs + # Skip host-state mutations on dry-run so --dry-run remains side-effect-free. + if [[ "${DRY_RUN}" -eq 0 ]]; then + ensure_host_dirs + fi check_codex_config parse_all_git_includes init_sandbox_path # Build bwrap argument list build_base_sandbox - build_binary_masks build_proc_dev_tmp build_sys_mounts build_etc_mounts @@ -487,6 +550,7 @@ main() { build_home_tmpfs build_workdir_mount build_codex_home_mount + build_codex_authoring_mounts build_git_mounts build_pixi_mounts build_ccache_mounts @@ -501,6 +565,10 @@ main() { build_env_vars build_codex_env + # Mask dangerous binaries as the very last step: a mask is hidden by any + # later bind that covers it (e.g. --ro-path /usr or /usr/bin). + build_binary_masks + # Launch if [[ "${DRY_RUN}" -eq 1 ]]; then print_dry_run diff --git a/bin/bwcopilot b/bin/bwcopilot index c7f3fe7..164e18c 100755 --- a/bin/bwcopilot +++ b/bin/bwcopilot @@ -3,7 +3,6 @@ # # Requires: bwrap >= 0.4.0 (RHEL 8) # - 0.4.0: base functionality -# - 0.5.0+: --clearenv (fallback: manual --unsetenv for each host var) # - 0.6.3+: bind over ro-bind (fallback: skip binary masking) # # The sandbox provides filesystem isolation: @@ -11,11 +10,13 @@ # - Copilot home (~/.copilot or $COPILOT_HOME): tmpfs (ephemeral by default) # - session-state/: bind-mounted read-write (persistent) # - logs/: bind-mounted read-write (persistent) -# - skills/: bind-mounted read-write (user-created skill definitions) -# - agents/: bind-mounted read-write (user-created custom agents) +# - skills/, agents/: bind-mounted read-write, always created on the host +# (user-authored definitions; persist across sessions) # - installed-plugins/: bind-mounted read-only (prevents agent from installing plugins) # - config.json: COPIED into tmpfs (host auth available, changes ephemeral) # - Use --init-auth to make config.json persistent (for personal machines) +# - Personal skills (~/.agents/skills): read-write (if present); skills +# symlinked in from elsewhere (e.g. a shared skills repository) work too # - Copilot cache (~/.cache/copilot or $COPILOT_CACHE_HOME): read-write # - GitHub CLI auth (~/.config/gh/hosts.yml): read-only (if it exists) # - Git config + includes: read-only @@ -63,6 +64,8 @@ # /etc/sudoers{,.d}. # --rw-path PATH Mount PATH read-write into the sandbox. May be repeated to # accumulate multiple paths. Same blocked-path rules as --ro-path. +# A nested --ro-path/--rw-path overrides the one it sits under, +# so `--rw-path DIR --ro-path DIR/sub` protects DIR/sub. set -euo pipefail @@ -157,7 +160,9 @@ Wrapper options (consumed before copilot sees the command line): Use this to run scripts, diagnostic tools, or any command with the same filesystem isolation and environment as copilot itself. Example: bwcopilot --exec bash - bwcopilot --exec 'env | sort' + bwcopilot --exec bash -c 'env | sort' + CMD itself is split on whitespace; pass arguments that need + quoting as separate words after it, as above. --init-auth Persist auth tokens to host (run once on personal machines). --new-session Force bwrap to call setsid(2), detaching the sandbox from the controlling terminal. This is the default on older kernels @@ -174,6 +179,9 @@ Wrapper options (consumed before copilot sees the command line): Symlinks are resolved before the safety check is applied. --rw-path PATH Mount PATH (file or directory) read-write into the sandbox. Same blocked-path rules as --ro-path. May be repeated. + --ro-path/--rw-path may nest: a nested path overrides the one + it sits under, so `--rw-path DIR --ro-path DIR/sub` keeps DIR + writable but DIR/sub read-only. --github-tokens Forward any GH_TOKEN_* environment variables into the sandbox so the agent can authenticate the gh(1) CLI. Off by default. When set, use per-command token selection: @@ -200,7 +208,7 @@ Known limitation: HELP_EOF # Delegate to copilot --help inside the sandbox so the user sees the full # copilot option set in the same invocation. - exec "$0" --exec "${_TOOL_BIN} --help" + bwrap_show_tool_help copilot } # ── Copilot path resolution ───────────────────────────────────── @@ -237,8 +245,6 @@ ensure_host_dirs() { mkdir -p "${COPILOT_HOME_DIR}" "${COPILOT_CACHE_DIR}" # Create persistent subdirectories for bind-mounts mkdir -p "${COPILOT_SESSION_DIR}" "${COPILOT_LOGS_DIR}" - # Create user-writable customization directories - mkdir -p "${COPILOT_HOME_DIR}/skills" "${COPILOT_HOME_DIR}/agents" } # ── Copilot home (ephemeral tmpfs + selective persistence) ────── @@ -254,8 +260,7 @@ ensure_host_dirs() { # Persistent read-write: # - session-state/: per-session checkpoints, events, database # - logs/: process logs -# - skills/: user-created skill definitions (SKILL.md files) -# - agents/: user-created custom agent profiles (*.agent.md files) +# - skills/, agents/: mounted by build_copilot_authoring_mounts # # Persistent read-only: # - mcp-config.json, lsp-config.json: user preferences (if they exist) @@ -288,30 +293,33 @@ build_copilot_home_mount() { fi done - # Bind-mount user-writable customization directories (read-write) - # Skills and agents are user-created files, not installed packages. - for _cfg in skills agents; do - local _path="${COPILOT_HOME_DIR}/${_cfg}" - if [[ -e "${_path}" ]]; then - BWRAP_ARGS+=(--bind "${_path}" "${_path}") - fi - done + # skills/ and agents/ (user-authored, read-write) are mounted by + # build_copilot_authoring_mounts, which runs after this function. - # Handle config.json: copy or bind depending on --init-auth + # Handle config.json: copy or bind depending on --init-auth. + # File creation is skipped on --dry-run to keep that mode side-effect-free. if [[ "${INIT_AUTH}" -eq 1 ]]; then # --init-auth: bind-mount config.json so changes persist - if [[ ! -f "${COPILOT_CONFIG_FILE}" ]]; then + if [[ ! -f "${COPILOT_CONFIG_FILE}" ]] && [[ "${DRY_RUN}" -eq 0 ]]; then ( umask 077 && printf '{"banner": "never"}\n' > "${COPILOT_CONFIG_FILE}" ) echo "Created ${COPILOT_CONFIG_FILE} — auth tokens will persist after this session." >&2 fi - BWRAP_ARGS+=(--bind "${COPILOT_CONFIG_FILE}" "${COPILOT_CONFIG_FILE}") + if _persist_bind_wanted "${COPILOT_CONFIG_FILE}"; then + BWRAP_ARGS+=(--bind "${COPILOT_CONFIG_FILE}" "${COPILOT_CONFIG_FILE}") + fi elif [[ -f "${COPILOT_CONFIG_FILE}" ]]; then # Normal mode: copy config.json into tmpfs via --file # This makes host auth available but changes don't persist - local _fd - exec {_fd}< "${COPILOT_CONFIG_FILE}" - BWRAP_ARGS+=(--file "${_fd}" "${COPILOT_CONFIG_FILE}") - # Note: fd stays open until bwrap exec's, then it's inherited and used + if [[ "${DRY_RUN}" -eq 1 ]]; then + # A real fd number could not be reproduced by copy-paste: print a + # placeholder for the --file mount instead. + BWRAP_ARGS+=(--file "" "${COPILOT_CONFIG_FILE}") + else + local _fd + exec {_fd}< "${COPILOT_CONFIG_FILE}" + BWRAP_ARGS+=(--file "${_fd}" "${COPILOT_CONFIG_FILE}") + # Note: fd stays open until bwrap exec's, then it's inherited and used + fi fi } @@ -339,6 +347,18 @@ build_copilot_settings_mount() { BWRAP_ARGS+=(--ro-bind "${_settings_source}" "${_settings_file}") } +# ── Copilot authoring directories (read-write, persistent) ───────── +# skills/ and agents/ hold user-authored markdown definitions. They are +# created on the host when missing and mounted read-write so the agent can +# create and edit them from inside the sandbox. ~/.agents/skills is an +# additional skills location, mounted only if it already exists. Must run +# AFTER build_copilot_home_mount (the home is a tmpfs that would hide them). +build_copilot_authoring_mounts() { + build_authoring_dir_mounts skill "${COPILOT_HOME_DIR}/skills" + build_authoring_dir_mounts agent "${COPILOT_HOME_DIR}/agents" + build_personal_authoring_mounts skill "${_HOME}/.agents/skills" +} + # ── Copilot cache (read-write) ────────────────────────────────── build_copilot_cache_mount() { BWRAP_ARGS+=(--bind "${COPILOT_CACHE_DIR}" "${COPILOT_CACHE_DIR}") @@ -379,28 +399,12 @@ build_copilot_env() { pass_through_if_set COPILOT_MODEL # Pass through any COPILOT_PROVIDER_* env vars - local _key - for _key in $(compgen -e); do - if [[ "${_key}" == COPILOT_PROVIDER_* ]]; then - BWRAP_ARGS+=(--setenv "${_key}" "${!_key}") - fi - done + forward_env_by_prefix COPILOT_PROVIDER_ - # GitHub CLI token passthrough (opt-in via --github-tokens) - # In --dry-run mode the value is replaced with REDACTED so that tokens - # are not printed to the terminal in the bwrap command output. + # GitHub CLI token passthrough (opt-in via --github-tokens). In + # --dry-run, print_dry_run redacts the values (the names match *TOKEN*). if [[ "${FORWARD_GH_TOKENS}" -eq 1 ]]; then - local _gh_val - while IFS= read -r _key; do - if [[ "${_key}" == GH_TOKEN_* ]]; then - if [[ "${DRY_RUN}" -eq 1 ]]; then - _gh_val="REDACTED" - else - _gh_val="${!_key}" - fi - BWRAP_ARGS+=(--setenv "${_key}" "${_gh_val}") - fi - done < <(compgen -e) + forward_env_by_prefix GH_TOKEN_ fi } @@ -411,31 +415,35 @@ build_copilot_env() { main() { bwrap_check_account parse_wrapper_args "$@" - bwrap_resolve_tool copilot "Install the standalone GitHub Copilot CLI from https://github.com/github/copilot-cli" if [[ "${SHOW_HELP}" -eq 1 ]]; then show_help - # show_help does exec, never returns + # show_help never returns fi + bwrap_resolve_tool copilot "Install the standalone GitHub Copilot CLI from https://github.com/github/copilot-cli" + detect_bwrap_capabilities detect_kernel_capabilities resolve_common_paths resolve_copilot_paths detect_shell - ensure_host_dirs + # Skip host-state mutations on dry-run so --dry-run remains side-effect-free. + if [[ "${DRY_RUN}" -eq 0 ]]; then + ensure_host_dirs + fi parse_all_git_includes init_sandbox_path # Build bwrap argument list build_base_sandbox - build_binary_masks build_proc_dev_tmp build_sys_mounts build_etc_mounts build_home_tmpfs build_workdir_mount build_copilot_home_mount + build_copilot_authoring_mounts build_copilot_cache_mount build_gh_auth_mount build_git_mounts @@ -460,6 +468,10 @@ main() { build_env_vars build_copilot_env + # Mask dangerous binaries as the very last step: a mask is hidden by any + # later bind that covers it (e.g. --ro-path /usr or /usr/bin). + build_binary_masks + # Launch if [[ "${DRY_RUN}" -eq 1 ]]; then print_dry_run diff --git a/bin/bwopencode b/bin/bwopencode index 4acad60..bc02a45 100755 --- a/bin/bwopencode +++ b/bin/bwopencode @@ -3,16 +3,27 @@ # # Requires: bwrap >= 0.4.0 (RHEL 8) # - 0.4.0: base functionality -# - 0.5.0+: --clearenv (fallback: manual --unsetenv for each host var) # - 0.6.3+: bind over ro-bind (fallback: skip binary masking) # +# Requires at least a minimal OpenCode config: /etc/opencode/opencode.json{,c} +# or a user opencode.json, opencode.jsonc or config.json in ~/.config/opencode. +# Exits 2 if neither exists. +# # The sandbox provides filesystem isolation: # - Current directory: read-write -# - OpenCode config: read-only (symlinks resolved) +# - OpenCode config: read-only (symlinks resolved), except the authoring +# directories below +# - Authoring directories (~/.config/opencode/{skills,agents,commands}): +# read-write, always created on the host, so definitions authored in the +# sandbox persist; skills symlinked in from elsewhere (e.g. a shared +# skills repository) work too +# - Additional skills/agents/commands locations OpenCode reads +# (~/.claude/{skills,agents,commands}, ~/.agents/{skills,agents}): +# read-write, only if they already exist # - OpenCode data dir: ephemeral tmpfs with selective persistence # - auth.json: persisted only if it already exists on host; # otherwise created in tmpfs (discarded on exit) -# - Database, logs, snapshots, storage, tool-output, +# - Database, logs, plans, snapshots, storage, tool-output, # project-dotfiles: always persisted # - OpenCode cache: read-write # - Git config + includes: read-only @@ -59,6 +70,8 @@ # /etc/sudoers{,.d}. # --rw-path PATH Mount PATH read-write into the sandbox. May be repeated to # accumulate multiple paths. Same blocked-path rules as --ro-path. +# A nested --ro-path/--rw-path overrides the one it sits under, +# so `--rw-path DIR --ro-path DIR/sub` protects DIR/sub. set -euo pipefail @@ -151,7 +164,9 @@ Wrapper options (consumed before opencode sees the command line): Use this to run scripts, diagnostic tools, or any command with the same filesystem isolation and environment as opencode itself. Example: bwopencode --exec bash - bwopencode --exec 'env | sort' + bwopencode --exec bash -c 'env | sort' + CMD itself is split on whitespace; pass arguments that need + quoting as separate words after it, as above. --init-auth Create auth.json on the host so GitHub OAuth tokens persist across sessions. Run once on a personal account; skip on shared accounts (tokens will be ephemeral by default). @@ -170,6 +185,9 @@ Wrapper options (consumed before opencode sees the command line): Symlinks are resolved before the safety check is applied. --rw-path PATH Mount PATH (file or directory) read-write into the sandbox. Same blocked-path rules as --ro-path. May be repeated. + --ro-path/--rw-path may nest: a nested path overrides the one + it sits under, so `--rw-path DIR --ro-path DIR/sub` keeps DIR + writable but DIR/sub read-only. --github-tokens Forward any GH_TOKEN_* environment variables into the sandbox so the agent can authenticate the gh(1) CLI. Off by default. When set, use per-command token selection: @@ -183,7 +201,7 @@ HELP_EOF # opencode option set in the same invocation. We re-exec this script with # --exec so the sandbox is fully constructed; opencode --help is passed # as the sandbox command. - exec "$0" --exec "${_TOOL_BIN} --help" + bwrap_show_tool_help opencode } resolve_opencode_paths() { @@ -238,17 +256,37 @@ resolve_config_symlinks() { [[ -d "${OC_CONFIG_DIR}" ]] || return 0 # The config directory itself may be (or sit under) a symlink. - _bwrap_canonicalize_safe "${OC_CONFIG_DIR}" "opencode config dir" + _bwrap_canonicalize_safe "${OC_CONFIG_DIR}" "opencode config dir" root local entry local_resolved for entry in "${OC_CONFIG_DIR}"/*; do [[ -e "${entry}" ]] || continue + # Authoring directories are mounted read-write by + # build_opencode_authoring_mounts; a read-only bind here would + # shadow that. + case "${entry##*/}" in skills | agents | commands) continue ;; esac _bwrap_canonicalize_safe "${entry}" "opencode config entry '${entry}'" local_resolved="${_BWRAP_CANON}" OC_CONFIG_BINDS+=("--ro-bind" "${local_resolved}" "${entry}") done } +# ── OpenCode authoring directories (read-write, persistent) ────── +# skills/, agents/ and commands/ under the OpenCode config directory hold +# user-authored markdown definitions. They are created on the host when +# missing and mounted read-write so the agent can create and edit them from +# inside the sandbox. The Claude- and agent-compatible locations OpenCode +# also reads are mounted read-write only if they already exist. Must run +# AFTER build_opencode_config_mount (which creates the config directory). +build_opencode_authoring_mounts() { + build_authoring_dir_mounts skill "${OC_CONFIG_DIR}/skills" + build_authoring_dir_mounts agent "${OC_CONFIG_DIR}/agents" + build_authoring_dir_mounts command "${OC_CONFIG_DIR}/commands" + build_personal_authoring_mounts skill "${_HOME}/.claude/skills" "${_HOME}/.agents/skills" + build_personal_authoring_mounts agent "${_HOME}/.claude/agents" "${_HOME}/.agents/agents" + build_personal_authoring_mounts command "${_HOME}/.claude/commands" +} + # ── Managed OpenCode config and skills (read-only, highest priority) ─ # If a managed config exists at /etc/opencode/ on the host (deployed # by ansible), bind-mount it RO into the sandbox. This is the Linux @@ -306,7 +344,11 @@ build_persistent_dotdir() { _repo_hash="$(printf '%s' "${_GIT_ROOT}" | sha256sum)" _repo_hash="${_repo_hash:0:16}" local _persist_dir="${OC_DATA_DIR}/project-dotfiles/${_repo_hash}" - mkdir -p "${_persist_dir}" + # The host mkdir is skipped on --dry-run (no host-state changes); the + # bind is still emitted so the printed command matches a real run. + if [[ "${DRY_RUN}" -eq 0 ]]; then + mkdir -p "${_persist_dir}" + fi BWRAP_ARGS+=( --bind "${_persist_dir}" "${_GIT_ROOT}/.opencode" @@ -333,7 +375,7 @@ build_opencode_config_mount() { # behaviour for shared accounts. On a personal account, run with # --init-auth once to create the file and bootstrap persistent auth. # -# Any files NOT in the persist list (e.g. mcp-auth.json, plans/) +# Any files NOT in the persist list (e.g. mcp-auth.json) # will be writable inside the sandbox but discarded on exit. build_opencode_data_mounts() { BWRAP_ARGS+=( @@ -354,12 +396,15 @@ build_opencode_data_mounts() { # auth.json: only persist if it already exists on the host. # Use --init-auth on first run to bootstrap persistent auth. + # File creation is skipped on --dry-run to keep that mode side-effect-free. if [[ "${INIT_AUTH}" -eq 1 ]] && [[ ! -f "${OC_AUTH_FILE}" ]]; then - # Use umask to ensure file is created with 0600 permissions atomically - ( umask 077 && printf '{}\n' > "${OC_AUTH_FILE}" ) - echo "Created ${OC_AUTH_FILE} — auth tokens will persist after this session." >&2 + if [[ "${DRY_RUN}" -eq 0 ]]; then + # Use umask to ensure file is created with 0600 permissions atomically + ( umask 077 && printf '{}\n' > "${OC_AUTH_FILE}" ) + echo "Created ${OC_AUTH_FILE} — auth tokens will persist after this session." >&2 + fi fi - if [[ -f "${OC_AUTH_FILE}" ]]; then + if _persist_bind_wanted "${OC_AUTH_FILE}"; then BWRAP_ARGS+=(--bind "${OC_AUTH_FILE}" "${OC_AUTH_FILE}") fi } @@ -455,7 +500,7 @@ build_opencode_env() { fi done if [[ "${_ok}" -eq 1 ]]; then - BWRAP_ARGS+=(--setenv "${_key}" "${!_key}") + SANDBOX_ENV+=("${_key}=${!_key}") else _dropped+=("${_key}") fi @@ -466,21 +511,10 @@ build_opencode_env() { echo "Note: dropped OPENCODE_* env vars not on the allowlist (names only): ${_dropped[*]}" >&2 fi - # GitHub CLI token passthrough (opt-in via --github-tokens) - # In --dry-run mode the value is replaced with REDACTED so that tokens - # are not printed to the terminal in the bwrap command output. + # GitHub CLI token passthrough (opt-in via --github-tokens). In + # --dry-run, print_dry_run redacts the values (the names match *TOKEN*). if [[ "${FORWARD_GH_TOKENS}" -eq 1 ]]; then - local _gh_val - while IFS= read -r _key; do - if [[ "${_key}" == GH_TOKEN_* ]]; then - if [[ "${DRY_RUN}" -eq 1 ]]; then - _gh_val="REDACTED" - else - _gh_val="${!_key}" - fi - BWRAP_ARGS+=(--setenv "${_key}" "${_gh_val}") - fi - done < <(compgen -e) + forward_env_by_prefix GH_TOKEN_ fi } @@ -491,26 +525,31 @@ build_opencode_env() { main() { bwrap_check_account parse_wrapper_args "$@" - bwrap_resolve_tool opencode if [[ "${SHOW_HELP}" -eq 1 ]]; then show_help - # show_help does exec, never returns + # show_help never returns fi + bwrap_resolve_tool opencode + detect_bwrap_capabilities detect_kernel_capabilities resolve_common_paths resolve_opencode_paths + # Fail before any host-state change (ensure_host_dirs) if nothing is configured. + require_opencode_config bwopencode "${OC_CONFIG_DIR}" detect_shell - ensure_host_dirs + # Skip host-state mutations on dry-run so --dry-run remains side-effect-free. + if [[ "${DRY_RUN}" -eq 0 ]]; then + ensure_host_dirs + fi parse_all_git_includes resolve_config_symlinks init_sandbox_path # Build bwrap argument list build_base_sandbox - build_binary_masks build_proc_dev_tmp build_sys_mounts build_etc_mounts @@ -520,6 +559,7 @@ main() { build_workdir_mount build_persistent_dotdir build_opencode_config_mount + build_opencode_authoring_mounts build_opencode_data_mounts build_opencode_cache_mount build_git_mounts @@ -535,6 +575,10 @@ main() { build_env_vars build_opencode_env + # Mask dangerous binaries as the very last step: a mask is hidden by any + # later bind that covers it (e.g. --ro-path /usr or /usr/bin). + build_binary_masks + # Launch if [[ "${DRY_RUN}" -eq 1 ]]; then print_dry_run diff --git a/bin/bwopencode2 b/bin/bwopencode2 index 3a6aade..e8e206c 100755 --- a/bin/bwopencode2 +++ b/bin/bwopencode2 @@ -3,32 +3,48 @@ # # Requires: bwrap >= 0.4.0 (RHEL 8) # - 0.4.0: base functionality -# - 0.5.0+: --clearenv (fallback: manual --unsetenv for each host var) # - 0.6.3+: bind over ro-bind (fallback: skip binary masking) # # Requires the `opencode2` command (npm @opencode/cli), which the ansible base # role installs. OpenCode 2 is a separate product from -# OpenCode 1 (see bwopencode) and never shares its directories. +# OpenCode 1 (see bwopencode) and keeps its config, data and cache apart from +# it on the host. +# +# On the host the opencode2 convention separates the directories +# (~/.config|.local/share|.cache/opencode2/opencode); inside the sandbox +# everything is mounted at the standard locations (~/.config/opencode, ...), so +# no XDG_* override is set. +# +# Requires at least a minimal OpenCode config: /etc/opencode/opencode.json{,c} +# or a user opencode.json, opencode.jsonc or config.json in +# ~/.config/opencode2/opencode. Exits 2 if neither exists. # # The sandbox provides filesystem isolation: # - Current directory: read-write -# - OpenCode 2 config (~/.config/opencode2/opencode): read-only (symlinks resolved); -# service.json / service-*.json (background-service settings) are not mounted -# - OpenCode 2 data (~/.local/share/opencode2/opencode): read-write, the -# whole directory persists (database incl. saved sign-in, logs, ...) -# - OpenCode 2 cache (~/.cache/opencode2/opencode): read-write -# - OpenCode 2 state (~/.local/state/opencode2/opencode): ephemeral +# - OpenCode 2 config (host ~/.config/opencode2/opencode, mounted at +# ~/.config/opencode): read-only (symlinks resolved); service.json / service-*.json +# (background-service settings) are not mounted +# - Authoring directories ({skills,agents,commands} under the host config +# directory, mounted under ~/.config/opencode): read-write, always created on the host, so +# definitions authored in the sandbox persist; skills symlinked in from +# elsewhere (e.g. a shared skills repository) work too +# - Additional skills/agents/commands locations OpenCode reads +# (~/.claude/{skills,agents,commands}, ~/.agents/{skills,agents}): +# read-write, only if they already exist +# - OpenCode 2 data (host ~/.local/share/opencode2/opencode, mounted at +# ~/.local/share/opencode): read-write, the whole directory persists +# (database incl. saved sign-in, logs, ...) +# - OpenCode 2 cache (host ~/.cache/opencode2/opencode, mounted at +# ~/.cache/opencode): read-write +# - OpenCode 2 state (~/.local/state/opencode): ephemeral # (last-used model and prompt history are not remembered) # - Managed config: /etc/opencode/opencode.json{,c} and # /etc/opencode/{agents,commands,plugins,skills}, read-only # - Project .opencode/: persistent copy kept outside the repository -# - XDG_{CONFIG,DATA,STATE,CACHE}_HOME point at the opencode2 directories so -# OpenCode 2 can never touch OpenCode 1 data (which it would migrate -# one way) -# - Git config + includes: read-only (also mirrored under the opencode2 -# XDG config home, where git looks inside the sandbox; only the -# allowed_signers, config, ignore and attributes files of git/ are -# mounted, never credentials) +# - OpenCode 1's config, database and other data are never mounted +# (OpenCode 2 would migrate the database one way) +# - Git config + includes: read-only (only the allowed_signers, config, +# ignore and attributes files of git/ are mounted, never credentials) # - OPENCODE_* env vars: only an allowlist of harmless switches is passed # (see build_opencode2_env); OPENCODE_SERVER_PASSWORD etc. are dropped # - Pixi cache: read-write @@ -76,6 +92,8 @@ # /etc/sudoers{,.d}. # --rw-path PATH Mount PATH read-write into the sandbox. May be repeated to # accumulate multiple paths. Same blocked-path rules as --ro-path. +# A nested --ro-path/--rw-path overrides the one it sits under, +# so `--rw-path DIR --ro-path DIR/sub` protects DIR/sub. # --github-tokens Forward any GH_TOKEN_* environment variables into the sandbox. # # There is no --init-auth: OpenCode 2 stores credentials in its database @@ -167,7 +185,9 @@ Wrapper options (consumed before opencode2 sees the command line): Use this to run scripts, diagnostic tools, or any command with the same filesystem isolation and environment as opencode2 itself. Example: bwopencode2 --exec bash - bwopencode2 --exec 'env | sort' + bwopencode2 --exec bash -c 'env | sort' + CMD itself is split on whitespace; pass arguments that need + quoting as separate words after it, as above. --new-session Force bwrap to call setsid(2), detaching the sandbox from the controlling terminal. This is the default on older kernels (< 5.14); on newer kernels we omit it so that SIGWINCH (terminal @@ -183,14 +203,19 @@ Wrapper options (consumed before opencode2 sees the command line): Symlinks are resolved before the safety check is applied. --rw-path PATH Mount PATH (file or directory) read-write into the sandbox. Same blocked-path rules as --ro-path. May be repeated. + --ro-path/--rw-path may nest: a nested path overrides the one + it sits under, so `--rw-path DIR --ro-path DIR/sub` keeps DIR + writable but DIR/sub read-only. --github-tokens Forward any GH_TOKEN_* environment variables into the sandbox so the agent can authenticate the gh(1) CLI. Off by default. When set, use per-command token selection: GH_TOKEN="$GH_TOKEN_NSLS2" gh pr list -R NSLS2/repo GH_TOKEN="$GH_TOKEN_PERSONAL" gh issue list -R user/repo -OpenCode 2 data lives under ~/.config|.local/share|.local/state|.cache/opencode2 -(never the OpenCode 1 directories). Sign-in is stored in the database in the +On the host OpenCode 2 data lives under ~/.local/share|.cache/opencode2/opencode +(never the OpenCode 1 directories); inside the sandbox it is mounted at the +standard locations (~/.local/share/opencode, ...), and the config is +~/.config/opencode2/opencode mounted at ~/.config/opencode. Sign-in is stored in the database in the persistent data directory, so there is no --init-auth. Sandbox-escape guard: OpenCode 2 normally connects to a shared background @@ -219,7 +244,7 @@ HELP_EOF # opencode2 option set in the same invocation. We re-exec this script with # --exec so the sandbox is fully constructed; opencode2 --help is passed # as the sandbox command. - exec "$0" --exec "${_TOOL_BIN} --help" + bwrap_show_tool_help opencode2 } # ── Sandbox-escape guard (arguments) ───────────────────────────── @@ -326,41 +351,48 @@ guard_tool_args() { } resolve_opencode2_paths() { - # Derived from the resolved $HOME, never from the (possibly overridden) - # XDG_* variables, so they match the ansible `opencode2` wrapper, which - # exports XDG_*_HOME=$HOME/.{config,local/share,local/state,cache}/opencode2. - # OpenCode 2 appends "opencode" to each of those. - OC2_XDG_CONFIG_HOME="${_HOME}/.config/opencode2" - OC2_XDG_DATA_HOME="${_HOME}/.local/share/opencode2" - OC2_XDG_STATE_HOME="${_HOME}/.local/state/opencode2" - OC2_XDG_CACHE_HOME="${_HOME}/.cache/opencode2" - OC2_CONFIG_DIR="${OC2_XDG_CONFIG_HOME}/opencode" - # The config directory holds service.json, which is never mounted: refuse - # a working directory / --ro-path / --rw-path that would bind it whole. - # shellcheck disable=SC2034 # read by _check_dir_contains_blocked in the library - EXTRA_BLOCKED_DIRS=("${OC2_CONFIG_DIR}") - OC2_DATA_DIR="${OC2_XDG_DATA_HOME}/opencode" - OC2_STATE_DIR="${OC2_XDG_STATE_HOME}/opencode" - OC2_CACHE_DIR="${OC2_XDG_CACHE_HOME}/opencode" + # Host side: the opencode2-specific directories, derived from the resolved + # $HOME (never from XDG_*), matching the ansible `opencode2` wrapper, which + # exports XDG_*_HOME=$HOME/.{config,local/share,local/state,cache}/opencode2 + # (OpenCode 2 appends "opencode" to each). They keep OpenCode 2 data apart + # from OpenCode 1 on the host. + OC2_CONFIG_SRC="${_HOME}/.config/opencode2/opencode" + OC2_DATA_SRC="${_HOME}/.local/share/opencode2/opencode" + OC2_CACHE_SRC="${_HOME}/.cache/opencode2/opencode" + + # Sandbox side: always the standard locations, which is where OpenCode 2 + # looks by default, so no XDG_* override is needed. The XDG_* values are + # the ones the shared library resolved (the host's, or ~/.config etc.), the + # same way bwopencode does. State is ephemeral at the default location. + OC2_CONFIG_DIR="${XDG_CONFIG_HOME}/opencode" + OC2_DATA_DIR="${XDG_DATA_HOME}/opencode" + OC2_CACHE_DIR="${XDG_CACHE_HOME}/opencode" + OC2_STATE_DIR="${_HOME}/.local/state/opencode" + + # The opencode2 config directory holds service.json, which is never + # mounted: refuse a working directory / --ro-path / --rw-path that would + # bind it whole. + # shellcheck disable=SC2034 # read by _bwrap_contains_blocked in the library + EXTRA_BLOCKED_DIRS=("${OC2_CONFIG_SRC}") # The fixed paths cannot reach a blocked path unless $HOME itself does; # the checks mirror bwopencode and also catch symlinked directories. - _check_dir_safe "${OC2_DATA_DIR}" "opencode2 data dir" - _check_dir_safe "${OC2_CACHE_DIR}" "opencode2 cache dir" + _check_dir_safe "${OC2_DATA_SRC}" "opencode2 data dir" + _check_dir_safe "${OC2_CACHE_SRC}" "opencode2 cache dir" } # Ensure writable directories exist on host. The data dir is bound whole # because the OpenCode 2 layout is not documented (database + -shm/-wal, log, # repos, shell, ...), so no file list could be kept in sync. ensure_host_dirs() { - mkdir -p "${OC2_DATA_DIR}" "${OC2_CACHE_DIR}" + mkdir -p "${OC2_DATA_SRC}" "${OC2_CACHE_SRC}" } # Resolve opencode2 config symlinks. -# We resolve each entry in ~/.config/opencode2/opencode/ so that symlinks -# pointing outside (e.g. into a dotfiles repo) are followed, and we -# bind the real file at the expected path. This avoids exposing the -# dotfiles tree inside the sandbox. +# We resolve each entry in the host config directory (OC2_CONFIG_SRC, +# ~/.config/opencode2/opencode/) so that symlinks pointing outside (e.g. into +# a dotfiles repo) are followed, and we bind the real file at the expected path +# under OC2_CONFIG_DIR, the standard location inside the sandbox. This avoids exposing the dotfiles tree inside the sandbox. # # Every path is canonicalized with realpath (readlink -f) and the canonical # result is passed through _check_path_safe before being added to @@ -371,13 +403,13 @@ ensure_host_dirs() { # the unresolved name alone would hide the real target. resolve_config_symlinks() { OC2_CONFIG_BINDS=() - [[ -d "${OC2_CONFIG_DIR}" ]] || return 0 + [[ -d "${OC2_CONFIG_SRC}" ]] || return 0 # The config directory itself may be (or sit under) a symlink. - _bwrap_canonicalize_safe "${OC2_CONFIG_DIR}" "opencode2 config dir" + _bwrap_canonicalize_safe "${OC2_CONFIG_SRC}" "opencode2 config dir" root local entry local_resolved - for entry in "${OC2_CONFIG_DIR}"/*; do + for entry in "${OC2_CONFIG_SRC}"/*; do [[ -e "${entry}" ]] || continue # service.json (or service-.json on other channels) holds the # background-service settings, including `service set env KEY VALUE` @@ -385,13 +417,35 @@ resolve_config_symlinks() { # file is not mounted. case "${entry##*/}" in service*.json) continue ;; + # Authoring directories are mounted read-write by + # build_opencode2_authoring_mounts; a read-only bind here would + # shadow that. + skills | agents | commands) continue ;; esac _bwrap_canonicalize_safe "${entry}" "opencode2 config entry '${entry}'" local_resolved="${_BWRAP_CANON}" - OC2_CONFIG_BINDS+=("--ro-bind" "${local_resolved}" "${entry}") + OC2_CONFIG_BINDS+=("--ro-bind" "${local_resolved}" "${OC2_CONFIG_DIR}/${entry##*/}") done } +# ── OpenCode 2 authoring directories (read-write, persistent) ──── +# skills/, agents/ and commands/ under the OpenCode 2 config directory hold +# user-authored markdown definitions. They are created on the host when +# missing and mounted read-write so the agent can create and edit them from +# inside the sandbox. They live under the host config directory +# (OC2_CONFIG_SRC) and are mounted at the standard location (OC2_CONFIG_DIR). +# The Claude- and agent-compatible locations OpenCode +# also reads are mounted read-write only if they already exist. Must run +# AFTER build_opencode2_config_mount (which creates the config directory). +build_opencode2_authoring_mounts() { + build_authoring_dir_mounts_from skill "${OC2_CONFIG_SRC}/skills" "${OC2_CONFIG_DIR}/skills" + build_authoring_dir_mounts_from agent "${OC2_CONFIG_SRC}/agents" "${OC2_CONFIG_DIR}/agents" + build_authoring_dir_mounts_from command "${OC2_CONFIG_SRC}/commands" "${OC2_CONFIG_DIR}/commands" + build_personal_authoring_mounts skill "${_HOME}/.claude/skills" "${_HOME}/.agents/skills" + build_personal_authoring_mounts agent "${_HOME}/.claude/agents" "${_HOME}/.agents/agents" + build_personal_authoring_mounts command "${_HOME}/.claude/commands" +} + # ── Managed OpenCode config (read-only, highest priority) ──────── # If a managed config exists at /etc/opencode/ on the host (deployed # by ansible), bind-mount it RO into the sandbox. OpenCode 2 reads @@ -422,7 +476,7 @@ build_managed_config() { fi else printf 'bwopencode2: warning: no /etc/opencode/opencode.json{,c} found on host.\n' >&2 - printf ' Falling back to user config at ~/.config/opencode2/opencode/.\n' >&2 + printf ' Falling back to user config at %s/.\n' "${OC2_CONFIG_SRC}" >&2 fi } @@ -453,7 +507,7 @@ build_managed_extras() { # repository-supplied .opencode/ content cannot inject config or plugins. # # The persistent directory is keyed by a hash of the bind dir path, -# stored under ~/.local/share/opencode2/opencode/project-dotfiles//. +# stored on the host under ~/.local/share/opencode2/opencode/project-dotfiles//. # Inside the sandbox, this is bind-mounted on top of /.opencode/. build_persistent_dotdir() { [[ -n "${_GIT_ROOT}" ]] || return 0 @@ -461,8 +515,12 @@ build_persistent_dotdir() { local _repo_hash _repo_hash="$(printf '%s' "${_GIT_ROOT}" | sha256sum)" _repo_hash="${_repo_hash:0:16}" - local _persist_dir="${OC2_DATA_DIR}/project-dotfiles/${_repo_hash}" - mkdir -p "${_persist_dir}" + local _persist_dir="${OC2_DATA_SRC}/project-dotfiles/${_repo_hash}" + # The host mkdir is skipped on --dry-run (no host-state changes); the + # bind is still emitted so the printed command matches a real run. + if [[ "${DRY_RUN}" -eq 0 ]]; then + mkdir -p "${_persist_dir}" + fi BWRAP_ARGS+=( --bind "${_persist_dir}" "${_GIT_ROOT}/.opencode" @@ -486,7 +544,7 @@ build_opencode2_config_mount() { # undocumented. build_opencode2_data_mount() { BWRAP_ARGS+=( - --bind "${OC2_DATA_DIR}" "${OC2_DATA_DIR}" + --bind "${OC2_DATA_SRC}" "${OC2_DATA_DIR}" ) } @@ -494,7 +552,7 @@ build_opencode2_data_mount() { # Provider npm packages are installed here, so it must be writable. build_opencode2_cache_mount() { BWRAP_ARGS+=( - --bind "${OC2_CACHE_DIR}" "${OC2_CACHE_DIR}" + --bind "${OC2_CACHE_SRC}" "${OC2_CACHE_DIR}" ) } @@ -508,57 +566,6 @@ build_opencode2_state_dir() { ) } -# ── XDG mirror for tools that read $XDG_CONFIG_HOME / $XDG_CACHE_HOME ─ -# Inside the sandbox XDG_CONFIG_HOME and XDG_CACHE_HOME point at the -# opencode2 directories, so git, pixi and ccache would no longer find -# their configuration at the host locations the shared library mounts. -# Make whatever the host has under its XDG config/cache home visible at the -# same relative location under the opencode2 homes. -# -# These are SYMLINKS to the host-path mounts the shared library already made -# (build_git_mounts / build_pixi_mounts / build_ccache_mounts), not second -# binds. Why: git resolves a relative include (`path = ../shared`) against -# the directory of the config path AS OPENED. A bind of the git dir at -# ~/.config/opencode2/git would make `../shared` resolve to -# ~/.config/opencode2/shared (which does not exist) and git would silently -# skip it. Through a symlinked directory the kernel resolves `..` against -# the real parent, so ~/.config/opencode2/git/../shared is the same file as -# ~/.config/shared, where the library mounted the include. The library -# mounts only allowed_signers, config, ignore and attributes of the host git -# directory (read-only, never the directory itself, so "credentials" and any -# file created on the host later stay invisible); the symlink exposes just -# those. If the host git directory is itself a symlink the library recreates -# it, so the mirror still resolves through it. Each symlink is created only -# when the library really mounted its target (otherwise it would dangle). -# Must run AFTER build_home_tmpfs and the library mount builders (reads -# _BWRAP_XDG_GIT_HAS_FILES). -# ~/.gitconfig and other $HOME-relative files are unaffected. -build_xdg_mirror_mounts() { - local _mirror_cfg="${OC2_XDG_CONFIG_HOME}" - - # Skip the mirror when the host XDG dirs already ARE the opencode2 ones. - if [[ "${XDG_CONFIG_HOME}" != "${_mirror_cfg}" ]]; then - if [[ "${_BWRAP_XDG_GIT_HAS_FILES}" -eq 1 ]]; then - BWRAP_ARGS+=(--symlink "${XDG_CONFIG_HOME}/git" "${_mirror_cfg}/git") - fi - # build_pixi_mounts binds exactly this file at its host path. - if [[ -f "${XDG_CONFIG_HOME}/pixi/config.toml" ]]; then - BWRAP_ARGS+=(--dir "${_mirror_cfg}/pixi" - --symlink "${XDG_CONFIG_HOME}/pixi/config.toml" "${_mirror_cfg}/pixi/config.toml") - fi - # ccache config is only XDG-relative when the legacy ~/.ccache is absent. - # build_ccache_mounts binds it at its host path when it is a directory. - if [[ "${CCACHE_CONFIG_DIR}" == "${XDG_CONFIG_HOME}/ccache" && -d "${CCACHE_CONFIG_DIR}" ]]; then - BWRAP_ARGS+=(--symlink "${CCACHE_CONFIG_DIR}" "${_mirror_cfg}/ccache") - fi - fi - if [[ "${XDG_CACHE_HOME}" != "${OC2_XDG_CACHE_HOME}" ]]; then - if [[ "${CCACHE_CACHE_DIR}" == "${XDG_CACHE_HOME}/ccache" && -d "${CCACHE_CACHE_DIR}" ]]; then - BWRAP_ARGS+=(--symlink "${CCACHE_CACHE_DIR}" "${OC2_XDG_CACHE_HOME}/ccache") - fi - fi -} - # ── OpenCode env vars ──────────────────────────────────────────── # Pass through ONLY the allowlisted OPENCODE_* env vars (harmless behaviour # switches). Every other OPENCODE_* variable is dropped, because the @@ -592,10 +599,8 @@ build_opencode2_install_mount() { fi } -# Must run AFTER build_env_vars: the XDG_* values set here override the -# host pass-through from build_env_vars (bwrap --setenv: last one wins), -# so OpenCode 2 uses its own directories even if `opencode2` on PATH is a -# plain binary rather than the ansible wrapper. +# Must run AFTER build_env_vars. No XDG_* override is set: OpenCode 2 finds +# its directories at the standard locations they are mounted at. build_opencode2_env() { # Allowlist of OPENCODE_* variables that may enter the sandbox. # Do NOT add anything that carries a secret, a port, a file path or a URL. @@ -615,7 +620,7 @@ build_opencode2_env() { # AIFAPIM gateway config pass_through_if_set AIFAPIM_HOST # The API key is passed through unchanged; print_dry_run redacts the value - # of any --setenv whose name looks secret (KEY/TOKEN/PASSWORD/...). + # of any variable whose name looks secret (KEY/TOKEN/PASSWORD/...). pass_through_if_set AIFAPIM_API_KEY local _key _allowed _name @@ -630,7 +635,7 @@ build_opencode2_env() { fi done if [[ "${_allowed}" -eq 1 ]]; then - BWRAP_ARGS+=(--setenv "${_key}" "${!_key}") + SANDBOX_ENV+=("${_key}=${!_key}") else _dropped+=("${_key}") fi @@ -641,31 +646,11 @@ build_opencode2_env() { printf 'bwopencode2: dry-run: dropped host OPENCODE_* variables (not in allowlist): %s\n' "${_dropped[*]}" >&2 fi - # GitHub CLI token passthrough (opt-in via --github-tokens) - # In --dry-run mode the value is replaced with REDACTED so that tokens - # are not printed to the terminal in the bwrap command output. + # GitHub CLI token passthrough (opt-in via --github-tokens). In + # --dry-run, print_dry_run redacts the values (the names match *TOKEN*). if [[ "${FORWARD_GH_TOKENS}" -eq 1 ]]; then - local _gh_val - while IFS= read -r _key; do - if [[ "${_key}" == GH_TOKEN_* ]]; then - if [[ "${DRY_RUN}" -eq 1 ]]; then - _gh_val="REDACTED" - else - _gh_val="${!_key}" - fi - BWRAP_ARGS+=(--setenv "${_key}" "${_gh_val}") - fi - done < <(compgen -e) + forward_env_by_prefix GH_TOKEN_ fi - - # Separate XDG homes for OpenCode 2 (see build_xdg_mirror_mounts for the - # git/pixi/ccache side effect). - BWRAP_ARGS+=( - --setenv XDG_CONFIG_HOME "${OC2_XDG_CONFIG_HOME}" - --setenv XDG_DATA_HOME "${OC2_XDG_DATA_HOME}" - --setenv XDG_STATE_HOME "${OC2_XDG_STATE_HOME}" - --setenv XDG_CACHE_HOME "${OC2_XDG_CACHE_HOME}" - ) } # ═══════════════════════════════════════════════════════════════════ @@ -675,13 +660,14 @@ build_opencode2_env() { main() { bwrap_check_account parse_wrapper_args "$@" - bwrap_resolve_tool opencode2 "OpenCode 2 is installed by the ansible base role." if [[ "${SHOW_HELP}" -eq 1 ]]; then show_help - # show_help does exec, never returns + # show_help never returns fi + bwrap_resolve_tool opencode2 "OpenCode 2 is installed by the ansible base role." + # --exec runs an arbitrary command, so its arguments are not opencode2's. if [[ -z "${TEST_CMD}" ]]; then guard_tool_args @@ -691,15 +677,19 @@ main() { detect_kernel_capabilities resolve_common_paths resolve_opencode2_paths + # Fail before any host-state change (ensure_host_dirs) if nothing is configured. + require_opencode_config bwopencode2 "${OC2_CONFIG_SRC}" detect_shell - ensure_host_dirs + # Skip host-state mutations on dry-run so --dry-run remains side-effect-free. + if [[ "${DRY_RUN}" -eq 0 ]]; then + ensure_host_dirs + fi parse_all_git_includes resolve_config_symlinks init_sandbox_path # Build bwrap argument list build_base_sandbox - build_binary_masks build_proc_dev_tmp build_sys_mounts build_etc_mounts @@ -710,6 +700,7 @@ main() { build_workdir_mount build_persistent_dotdir build_opencode2_config_mount + build_opencode2_authoring_mounts build_opencode2_data_mount build_opencode2_cache_mount build_opencode2_state_dir @@ -717,7 +708,6 @@ main() { build_pixi_mounts build_ccache_mounts build_npm_mounts - build_xdg_mirror_mounts build_user_bin_mount build_nsls2_mount build_opencode2_install_mount @@ -728,6 +718,10 @@ main() { build_env_vars build_opencode2_env + # Mask dangerous binaries as the very last step: a mask is hidden by any + # later bind that covers it (e.g. --ro-path /usr or /usr/bin). + build_binary_masks + # Launch if [[ "${DRY_RUN}" -eq 1 ]]; then print_dry_run diff --git a/bin/gh-protect-branch b/bin/gh-protect-branch index a3b4488..8408e1b 100755 --- a/bin/gh-protect-branch +++ b/bin/gh-protect-branch @@ -55,6 +55,10 @@ # --exclude-branches Comma-separated list of fnmatch branch-name # patterns to allow creation of. Each entry is # matched against refs/heads/. +# Patterns are comma-split, so a comma cannot +# appear within a single pattern; each pattern must +# match ^[A-Za-z0-9._/*?-]+$ (letters, digits and +# . _ / * ? -). # Default: main,preview # Requires --restrict-branch-creation or # --only-restrict-creation. @@ -134,7 +138,7 @@ while [[ "${1:-}" == -* ]]; do ;; *) echo "Error: unknown option: $1" >&2 - show_help + show_help >&2 exit 1 ;; esac @@ -164,15 +168,66 @@ if [[ "${EXCLUDE_CSV_SET}" == "true" && exit 1 fi +# ── Validate user-supplied values (before any repo mutation) ─────────────────── +# These checks run BEFORE any API call so a malformed value cannot abort the run +# mid-way and leave the repository partially protected. They also confine the +# values that later flow into JSON payloads / API URLs to a safe charset, +# closing the JSON-injection / URL-rewrite classes at the source. + +if [[ -n "${APPROVERS_TEAM}" && ! "${APPROVERS_TEAM}" =~ ^[A-Za-z0-9_-]+$ ]]; then + echo "Error: --approvers slug must match ^[A-Za-z0-9_-]+\$ (letters, digits, '_', '-')." >&2 + echo " Got: '${APPROVERS_TEAM}'" >&2 + exit 1 +fi + +# Validate every --exclude-branches pattern. Patterns are comma-split, so a +# comma can never appear within a single pattern (documented in --help). Each +# trimmed pattern must be non-empty and match a safe fnmatch charset. +if [[ "${RESTRICT_CREATION}" == "true" || "${ONLY_RESTRICT_CREATION}" == "true" ]]; then + IFS=',' read -ra _exclude_pats <<< "${EXCLUDE_CSV}" + for _pat in "${_exclude_pats[@]}"; do + # trim leading/trailing whitespace + _pat="${_pat#"${_pat%%[![:space:]]*}"}" + _pat="${_pat%"${_pat##*[![:space:]]}"}" + if [[ -z "${_pat}" ]]; then + echo "Error: --exclude-branches contains an empty pattern (check for stray commas)." >&2 + exit 1 + fi + if [[ ! "${_pat}" =~ ^[A-Za-z0-9._/*?-]+$ ]]; then + echo "Error: --exclude-branches pattern '${_pat}' contains invalid characters." >&2 + echo " Allowed: letters, digits and . _ / * ? - (commas separate patterns)." >&2 + exit 1 + fi + done + unset _exclude_pats _pat +fi + # ── Positional arguments ─────────────────────────────────────────────────────── if [[ -z "${1:-}" ]]; then - show_help + show_help >&2 exit 1 fi REPO="$1" -BRANCH="${2:-main}" +shift + +# Consume the optional [branch] (only meaningful for full protection). Any +# remaining positional argument is unexpected and is a usage error. +BRANCH="main" +if [[ "${ONLY_RESTRICT_CREATION}" == "false" && $# -gt 0 ]]; then + BRANCH="$1" + shift +fi + +if [[ $# -gt 0 ]]; then + echo "Error: unexpected extra positional argument(s): $*" >&2 + if [[ "${ONLY_RESTRICT_CREATION}" == "true" ]]; then + echo " --only-restrict-creation takes only (no [branch])." >&2 + fi + show_help >&2 + exit 1 +fi if [[ ! "${REPO}" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then echo "Error: repository must be in 'owner/repo' format (e.g. NSLS2/n2snscripts)." >&2 @@ -208,26 +263,48 @@ fi # ── Build the creation-restriction ruleset exclude JSON ─────────────────────── # Each CSV entry is trimmed and prefixed with refs/heads/ so bare names like # "main" match correctly. fnmatch patterns (e.g. release/**) are preserved. +# The pre-flight validation loop (lines 186-203) already guaranteed every pattern +# is non-empty and matches the safe charset; this just builds the JSON array. _build_exclude_json() { local csv="$1" - local result="" local p + local _pats=() + local _refs=() IFS=',' read -ra _pats <<< "${csv}" for p in "${_pats[@]}"; do # trim leading/trailing whitespace p="${p#"${p%%[![:space:]]*}"}" p="${p%"${p##*[![:space:]]}"}" - [[ -z "$p" ]] && continue - result+="${result:+, }\"refs/heads/${p}\"" + _refs+=("refs/heads/${p}") done - if [[ -z "${result}" ]]; then - echo "Error: --exclude-branches produced no usable patterns after trimming." >&2 - echo " At least one non-empty branch name or pattern is required." >&2 + # Emit a JSON array of ref strings; jq encodes each value safely. + jq -n --args '$ARGS.positional' "${_refs[@]}" +} + +# ── Fetch repository rulesets once (used by both full protection and creation restriction) +# When both rulesets are needed in one invocation (ONLY_RESTRICT_CREATION=false + +# RESTRICT_CREATION=true), this fetch runs once and both IDs are extracted from +# the same response. When only one ruleset is needed, the fetch still happens +# lazily/correctly in its own section below. +# includes_parents=false excludes org-inherited rulesets so an org ruleset with +# the same name cannot be mistaken for a repo one (which would 404 on PUT). +# The query string is embedded in the URL so --paginate's link-header following +# preserves it across pages. +RULESETS_JSON="" +RULESETS_FETCHED=0 +if [[ "${ONLY_RESTRICT_CREATION}" == "false" && "${RESTRICT_CREATION}" == "true" ]]; then + # Both rulesets needed — fetch once. Track the fetch with a flag rather + # than testing RULESETS_JSON for emptiness: a repo with no rulesets at all + # (the common first run) legitimately yields an empty stream. + if ! RULESETS_JSON=$(gh api --paginate "repos/${REPO}/rulesets?includes_parents=false" \ + --jq '.[]'); then + echo "Error: failed to query existing rulesets on ${REPO}." >&2 + echo " Aborting to avoid creating duplicate rulesets." >&2 exit 1 fi - printf '%s' "${result}" -} + RULESETS_FETCHED=1 +fi # ── Full branch protection (steps 1–4) ──────────────────────────────────────── # Skipped when --only-restrict-creation is set. @@ -235,45 +312,70 @@ _build_exclude_json() { if [[ "${ONLY_RESTRICT_CREATION}" == "false" ]]; then echo "Applying branch protection to ${REPO}@${BRANCH}..." - # Build the rules array. Signed commits are conditional. + # Build the conditional signed-commits rule as a (possibly empty) JSON array + # that is appended to the base rules; keeps the emitted shape identical to + # the previous printf assembly. if [[ "${SIGN_COMMITS}" == "true" ]]; then - SIGN_RULE=', - { "type": "required_signatures" }' + SIGN_RULES='[{ "type": "required_signatures" }]' else - SIGN_RULE='' + SIGN_RULES='[]' fi # Compose the ruleset payload (used for both create and update). - RULESET_PAYLOAD=$(printf '{ - "name": "%s", - "target": "branch", - "enforcement": "active", - "conditions": { - "ref_name": { - "include": ["~ALL"], - "exclude": [] - } - }, - "rules": [ - { "type": "deletion" }, - { "type": "non_fast_forward" }, - { - "type": "pull_request", - "parameters": { - "required_approving_review_count": 1, - "dismiss_stale_reviews_on_push": true, - "require_last_push_approval": true, - "require_code_owner_review": true, - "required_review_thread_resolution": true - } - }%s - ] -}' "${FULL_RULESET_NAME}" "${SIGN_RULE}") + RULESET_PAYLOAD=$(jq -n \ + --arg name "${FULL_RULESET_NAME}" \ + --argjson sign "${SIGN_RULES}" \ + '{ + name: $name, + target: "branch", + enforcement: "active", + conditions: { + ref_name: { + include: ["~ALL"], + exclude: [] + } + }, + rules: ([ + { type: "deletion" }, + { type: "non_fast_forward" }, + { + type: "pull_request", + parameters: { + required_approving_review_count: 1, + dismiss_stale_reviews_on_push: true, + require_last_push_approval: true, + require_code_owner_review: true, + required_review_thread_resolution: true + } + } + ] + $sign) + }') # 1) Repository ruleset (all branches via ~ALL) — idempotent. # Check for an existing ruleset with our name; PUT if found, POST if not. - RULESET_ID=$(gh api "repos/${REPO}/rulesets" \ - --jq ".[] | select(.name==\"${FULL_RULESET_NAME}\") | .id" 2> /dev/null || true) + # --paginate walks every page (the default 30/page can hide a match); + # first(...) collapses any duplicate-name rulesets to a single id. A real + # API error is distinguished from an empty result so a transient failure + # is not misread as "not found" (which would POST a duplicate). + if [[ "${RULESETS_FETCHED}" -eq 1 ]]; then + # Reuse the already-fetched rulesets list (both rulesets needed). + # -s slurps the newline-separated object stream into one array + # (interpolating it into an --argjson literal would be invalid JSON + # as soon as there is more than one ruleset). + RULESET_ID=$(jq -rs --arg name "${FULL_RULESET_NAME}" \ + 'first(.[] | select(.name==$name) | .id) // ""' \ + <<< "${RULESETS_JSON}") + else + # Only full protection needed — fetch rulesets now. + if ! RULESET_ID=$(gh api --paginate "repos/${REPO}/rulesets?includes_parents=false" \ + --jq "first(.[] | select(.name==\"${FULL_RULESET_NAME}\") | .id)"); then + echo "Error: failed to query existing rulesets on ${REPO}." >&2 + echo " Aborting to avoid creating a duplicate ruleset." >&2 + exit 1 + fi + fi + # Guard against a newline-joined id if multiple pages each emit a match. + RULESET_ID="${RULESET_ID%%$'\n'*}" if [[ -n "${RULESET_ID}" ]]; then printf '%s' "${RULESET_PAYLOAD}" | @@ -293,25 +395,27 @@ if [[ "${ONLY_RESTRICT_CREATION}" == "false" ]]; then # restrictions. The Ruleset covers the substantive rules for all # branches; the classic rule adds admin enforcement on the named branch # and (when --approvers is set) restricts who can dismiss stale reviews. - if [[ -n "${APPROVERS_TEAM}" ]]; then - DISMISSAL_JSON='"dismissal_restrictions": { "teams": ["'"${APPROVERS_TEAM}"'"] },' - else - DISMISSAL_JSON='' - fi - - printf '{ - "required_status_checks": null, - "enforce_admins": true, - "required_pull_request_reviews": { - %s - "required_approving_review_count": 1, - "dismiss_stale_reviews": true, - "require_last_push_approval": true, - "require_code_owner_reviews": true - }, - "restrictions": null, - "required_conversation_resolution": true -}' "${DISMISSAL_JSON}" | + # jq -n builds the payload with APPROVERS_TEAM safely encoded. When no team + # is set, the dismissal_restrictions key is omitted entirely (identical to + # the previous empty-string interpolation). + jq -n --arg team "${APPROVERS_TEAM}" ' + { + required_status_checks: null, + enforce_admins: true, + required_pull_request_reviews: ( + (if $team == "" then {} + else { dismissal_restrictions: { teams: [$team] } } + end) + + { + required_approving_review_count: 1, + dismiss_stale_reviews: true, + require_last_push_approval: true, + require_code_owner_reviews: true + } + ), + restrictions: null, + required_conversation_resolution: true + }' | gh api -X PUT "repos/${REPO}/branches/${BRANCH}/protection" \ -H "Accept: application/vnd.github+json" \ --input - > /dev/null @@ -359,24 +463,42 @@ fi if [[ "${RESTRICT_CREATION}" == "true" || "${ONLY_RESTRICT_CREATION}" == "true" ]]; then EXCLUDE_JSON=$(_build_exclude_json "${EXCLUDE_CSV}") - CREATION_PAYLOAD=$(printf '{ - "name": "%s", - "target": "branch", - "enforcement": "active", - "conditions": { - "ref_name": { - "include": ["~ALL"], - "exclude": [%s] - } - }, - "rules": [ - { "type": "creation" } - ] -}' "${CREATION_RULESET_NAME}" "${EXCLUDE_JSON}") - - CREATION_RULESET_ID=$(gh api "repos/${REPO}/rulesets" \ - --jq ".[] | select(.name==\"${CREATION_RULESET_NAME}\") | .id" \ - 2> /dev/null || true) + CREATION_PAYLOAD=$(jq -n \ + --arg name "${CREATION_RULESET_NAME}" \ + --argjson exclude "${EXCLUDE_JSON}" \ + '{ + name: $name, + target: "branch", + enforcement: "active", + conditions: { + ref_name: { + include: ["~ALL"], + exclude: $exclude + } + }, + rules: [ + { type: "creation" } + ] + }') + + # --paginate + first(...) + explicit error handling, as above. + if [[ "${RULESETS_FETCHED}" -eq 1 ]]; then + # Reuse the already-fetched rulesets list (both rulesets needed); + # -s slurps the object stream into one array (see the full-protection + # lookup above). + CREATION_RULESET_ID=$(jq -rs --arg name "${CREATION_RULESET_NAME}" \ + 'first(.[] | select(.name==$name) | .id) // ""' \ + <<< "${RULESETS_JSON}") + else + # Only creation restriction needed (--only-restrict-creation) — fetch now. + if ! CREATION_RULESET_ID=$(gh api --paginate "repos/${REPO}/rulesets?includes_parents=false" \ + --jq "first(.[] | select(.name==\"${CREATION_RULESET_NAME}\") | .id)"); then + echo "Error: failed to query existing rulesets on ${REPO}." >&2 + echo " Aborting to avoid creating a duplicate ruleset." >&2 + exit 1 + fi + fi + CREATION_RULESET_ID="${CREATION_RULESET_ID%%$'\n'*}" if [[ -n "${CREATION_RULESET_ID}" ]]; then printf '%s' "${CREATION_PAYLOAD}" | diff --git a/bin/pemdecompose b/bin/pemdecompose index c21ffcd..21d9a7a 100755 --- a/bin/pemdecompose +++ b/bin/pemdecompose @@ -20,25 +20,26 @@ # -h, --help Show this help and exit # --verify Verify each cert is issued by the next cert in the # file (signature + issuer/subject DN match). -# -q, --quiet Suppress the per-file banner and inter-file blank -# line when multiple files are given. -# --no-color Disable ANSI colour. Colour is also disabled -# automatically when stdout is not a TTY or when the -# NO_COLOR environment variable is set. +# -q, --quiet Suppress the per-file banner and inter-file blank +# line when multiple files are given. +# --no-color, Disable ANSI colour. Colour is also disabled +# --no-colour automatically when stdout is not a TTY or when the +# NO_COLOR environment variable is set. # # Exit codes: # 0 success -# 1 usage error +# 1 usage error (bad option, no input file, or an unreadable input file) # 2 no certificates found in at least one input file # 3 openssl parse error on at least one input file # 4 --verify failed for at least one chain link +# 5 operational error (e.g. cannot create the temporary directory) set -euo pipefail PROG="pemdecompose" usage() { - sed -n '2,/^$/p' "$0" | sed -e 's/^# \{0,1\}//' + sed -n '2,/^$/{ s/^# \{0,1\}//; p; }' "$0" } die() { @@ -124,11 +125,21 @@ cleanup() { fi return 0 } -trap cleanup EXIT INT TERM HUP +# Run cleanup once on normal exit. For fatal signals, clean up and then +# re-raise the signal after clearing both the signal and EXIT traps so the +# process dies with the conventional 128+N status and cleanup is not run a +# second time. +trap cleanup EXIT +trap 'cleanup; trap - INT EXIT; kill -INT $$' INT +trap 'cleanup; trap - TERM EXIT; kill -TERM $$' TERM +trap 'cleanup; trap - HUP EXIT; kill -HUP $$' HUP ensure_tmpdir() { if [[ -z "$TMPDIR_" ]]; then - TMPDIR_=$(mktemp -d -t pemdecompose.XXXXXX) + if ! TMPDIR_=$(mktemp -d -t pemdecompose.XXXXXX); then + warn "failed to create temporary directory" + return 1 + fi fi } @@ -150,27 +161,27 @@ split_certs() { ' "$file" } -# Print just the subject DN (everything after "subject="). -cert_subject_only() { - openssl x509 -noout -subject -nameopt RFC2253 2> /dev/null | - sed -e 's/^subject=//' -} - -# Print just the issuer DN. -cert_issuer_only() { - openssl x509 -noout -issuer -nameopt RFC2253 2> /dev/null | - sed -e 's/^issuer=//' -} - -# Print the top-level Signature Algorithm name (e.g. ecdsa-with-SHA384, -# sha256WithRSAEncryption, ED25519). Returns nonzero if openssl can't -# parse the block. -cert_sigalg() { - openssl x509 -noout -text 2> /dev/null | - awk '/Signature Algorithm:/ { - sub(/^.*Signature Algorithm:[[:space:]]*/, "") - print; exit - }' +# Print the subject DN, issuer DN, and signature algorithm from a single +# openssl invocation. Output format: three lines "subject=...", "issuer=...", +# "sigalg=...". Returns nonzero if openssl can't parse the block. +cert_info() { + local output + if ! output=$(openssl x509 -noout -subject -issuer -text -nameopt RFC2253 2> /dev/null); then + return 1 + fi + local subject="" issuer="" sigalg="" + local line + while IFS= read -r line; do + case "$line" in + subject=*) subject="${line#subject=}" ;; + issuer=*) issuer="${line#issuer=}" ;; + *Signature\ Algorithm:*) + sigalg="${line##*Signature Algorithm:}" + sigalg="${sigalg#"${sigalg%%[![:space:]]*}"}" + ;; + esac + done <<< "$output" + printf 'subject=%s\nissuer=%s\nsigalg=%s\n' "$subject" "$issuer" "$sigalg" } # --- per-file processing ------------------------------------------------ @@ -181,7 +192,7 @@ process_file() { if [[ ! -r "$file" ]]; then warn "cannot read: $file" - return 3 + return 1 fi if [[ "$show_banner" -eq 1 ]]; then @@ -202,24 +213,27 @@ process_file() { return 2 fi - # Emit subject + issuer + sigalg per cert. + # Emit subject + issuer + sigalg per cert. Parse all three fields from + # one openssl invocation and cache subject/issuer for the --verify loop. + local -a subjects=() issuers=() local i for ((i = 0; i < count; i++)); do local idx=$((i + 1)) - local sub iss sigalg - if ! sub=$(printf '%s' "${blocks[$i]}" | cert_subject_only); then - warn "openssl failed to parse certificate #$idx in $file" - return 3 - fi - if ! iss=$(printf '%s' "${blocks[$i]}" | cert_issuer_only); then - warn "openssl failed to parse certificate #$idx in $file" - return 3 - fi - if ! sigalg=$(printf '%s' "${blocks[$i]}" | cert_sigalg); then + local info sub="" iss="" sigalg="" line + if ! info=$(printf '%s' "${blocks[$i]}" | cert_info); then warn "openssl failed to parse certificate #$idx in $file" return 3 fi + while IFS= read -r line; do + case "$line" in + subject=*) sub="${line#subject=}" ;; + issuer=*) iss="${line#issuer=}" ;; + sigalg=*) sigalg="${line#sigalg=}" ;; + esac + done <<< "$info" [[ -z "$sigalg" ]] && sigalg="" + subjects[i]="$sub" + issuers[i]="$iss" printf '%s%2d%s %ssubject:%s %s\n' \ "$C_BOLD" "$idx" "$C_RESET" "$C_DIM" "$C_RESET" "$sub" printf ' %sissuer :%s %s\n' \ @@ -238,7 +252,12 @@ process_file() { return 0 fi - ensure_tmpdir + # Map a tmpdir-creation failure to the documented operational-error + # code rather than 1 (which the exit-code table reserves for usage + # errors). + if ! ensure_tmpdir; then + return 5 + fi local link_ok=1 # Write each block to a temp file once. @@ -258,9 +277,8 @@ process_file() { local nxt="${cert_files[$((j + 1))]}" local cur_issuer nxt_subject - cur_issuer=$(printf '%s' "${blocks[$j]}" | cert_issuer_only) || true - nxt_subject=$(printf '%s' "${blocks[$((j + 1))]}" | - cert_subject_only) || true + cur_issuer="${issuers[$j]}" + nxt_subject="${subjects[$((j + 1))]}" local dn_ok=0 if [[ -n "$cur_issuer" && "$cur_issuer" == "$nxt_subject" ]]; then @@ -280,11 +298,11 @@ process_file() { link_ok=0 local reason="" if [[ $dn_ok -eq 0 && $sig_ok -eq 0 ]]; then - reason="DN mismatch and signature mismatch" + reason="issuer DN mismatch and signature verification failed" elif [[ $dn_ok -eq 0 ]]; then reason="issuer DN mismatch" else - reason="signature mismatch" + reason="signature verification failed" fi printf ' %d -> %d %sFAIL%s (%s)\n' \ "$cur_idx" "$nxt_idx" "$C_RED" "$C_RESET" "$reason" @@ -317,7 +335,7 @@ fi worst_rc=0 first=1 for file in "${FILES[@]}"; do - if [[ $first -eq 0 && $QUIET -eq 0 && $show_banner -eq 1 ]]; then + if [[ $first -eq 0 && $show_banner -eq 1 ]]; then printf '\n' fi first=0 diff --git a/docs/bw-wrappers.md b/docs/bw-wrappers.md index 295ce2b..184e806 100644 --- a/docs/bw-wrappers.md +++ b/docs/bw-wrappers.md @@ -16,7 +16,6 @@ option list; the tables below summarise the common surface. - `bash` >= 4.x (the wrappers use `[[ ]]` and arrays) - `bubblewrap` (`bwrap`) >= 0.4.0 - - 0.5.0+ enables `--clearenv` - 0.6.3+ enables bind-over-ro-bind binary masking ## Managed system paths @@ -46,19 +45,59 @@ read-only. Contribute or update skills through the deployment's documented contribution workflow; do not edit its deployed checkout or managed aliases directly. +## Authoring directories + +Each wrapper mounts, read-write, the directories where its tool reads +user-authored markdown definitions, so skills, agents, commands and prompts +can be created and edited from inside the sandbox and persist on the host. +The primary directories are created on the host when missing (not under +`--dry-run`); the additional locations are mounted only when they already +exist. + +| Wrapper | Primary (always created) | Additional (if present) | +| --- | --- | --- | +| `bwopencode` | `~/.config/opencode/{skills,agents,commands}` | `~/.claude/{skills,agents,commands}`, `~/.agents/{skills,agents}` | +| `bwopencode2` | `~/.config/opencode2/opencode/{skills,agents,commands}` | `~/.claude/{skills,agents,commands}`, `~/.agents/{skills,agents}` | +| `bwclaude` | `~/.claude/{skills,agents,commands}` (under `$CLAUDE_CONFIG_DIR` when that is set) | | +| `bwcopilot` | `~/.copilot/{skills,agents}` | `~/.agents/skills` | +| `bwcodex` | `~/.codex/{skills,prompts}` | `~/.agents/skills` | + +A common setup keeps skills in a separate repository and symlinks each one in +(for example `~/.agents/skills/some-skill -> ~/src/.../skills/some-skill`). +Each such symlink's resolved target is mounted read-write too, so the skill +works in the sandbox and editing it edits its source repository; the rest of +that repository is not exposed. A symlink that resolves into a blocked +location (such as `~/.ssh`), or to a directory that contains one, is skipped +with a warning, as is an authoring directory that does. + +If the authoring directory itself is a symlink (for example +`~/.config/opencode/skills -> ~/dotfiles/skills`), its resolved target is +mounted at the usual path, but symlinks inside it are not followed this way; +each entry is mounted individually instead. Existing entries work, but a new +top-level entry created in the sandbox is discarded on exit. + ## Common options Every `bw*` wrapper accepts these options: | Option | Effect | | --- | --- | -| `--help`, `-h` | Show wrapper help plus the underlying tool's help | +| `--help`, `-h` | Show wrapper help, then the underlying tool's help when the tool is installed | | `--dry-run` | Print the `bwrap` command without executing it | -| `--exec CMD` | Run `CMD` inside the sandbox instead of the tool | +| `--exec CMD [ARGS...]` | Run `CMD` inside the sandbox instead of the tool. `CMD` is split on whitespace; following arguments are passed unchanged, e.g. `--exec bash -c 'env \| sort'` | | `--init-auth` | Persist auth credentials to the host (first-time setup; not available in `bwopencode2`) | | `--new-session` | Force `bwrap --new-session` (stricter isolation; breaks SIGWINCH) | | `--ro-path PATH` | Read-only mount an existing path; its canonical target is mounted at the same canonical destination. Use only narrow paths. | | `--rw-path PATH` | Read-write mount an existing path; its canonical target is mounted at the same canonical destination. Use only narrow paths. | +| `--github-tokens` | Forward every `GH_TOKEN_*` environment variable into the sandbox so the agent can authenticate `gh`. Off by default. In `--dry-run` mode, token values are printed as `REDACTED`. Use per-command token selection inside the sandbox: `GH_TOKEN="$GH_TOKEN_NSLS2" gh pr list -R NSLS2/repo` | + +`--ro-path` and `--rw-path` may be nested: a path nested under another +overrides it, so `--rw-path DIR --ro-path DIR/sub` mounts `DIR` read-write but +keeps `DIR/sub` read-only (the reverse, a read-only tree with one writable +subdirectory, works too). A path given as both `--ro-path` and `--rw-path` is +rejected. The masks on privileged binaries are the very last step, applied after all +other mounts and the environment setup, so a path that covers their directory +(for example `--ro-path /usr`) does not undo them. Tool-specific options are listed in each wrapper's section below. @@ -196,10 +235,17 @@ cases. settings inside the sandbox. That does not expose credentials, so it is treated as a limitation of the sandbox, not a bug, and the include handling is not being extended further. -- **`--dry-run` redacts secrets.** Every printed `--setenv NAME VALUE` - whose `NAME` contains (case-insensitive) `KEY`, `TOKEN`, `PASSWORD`, - `SECRET`, or `CREDENTIAL` is printed with the value `REDACTED`. Only the - printed text is redacted; the real launch passes the real values. +- **Forwarded variables stay off the command line.** Values passed through + from the host (API keys, `GH_TOKEN_*`, ...) are given to `bwrap` in its + environment via `env -i`, not as `--setenv` arguments, so they do not + appear in `ps` output for the running sandbox (`/proc//environ` is + readable only by you). They are visible on `env`'s own command line only + for the instant before it starts `bwrap`. +- **`--dry-run` redacts secrets.** Every printed variable (on the `env -i` + line or as `--setenv NAME VALUE`) whose `NAME` contains (case-insensitive) + `KEY`, `TOKEN`, `PASSWORD`, `SECRET`, or `CREDENTIAL` is printed with the + value `REDACTED`. Only the printed text is redacted; the real launch passes + the real values. ## `bwopencode` @@ -209,14 +255,17 @@ Launches [opencode](https://opencode.ai) inside a bubblewrap sandbox. bwopencode [bwopencode-options] [opencode arguments...] ``` -Persists database, logs, snapshots, storage, tool-output, and +Persists database, logs, plans, snapshots, storage, tool-output, and project-dotfiles to the host data dir; `auth.json` is persisted only if it already exists on the host (use `--init-auth` once to create it for personal accounts). -| Option | Description | -| --- | --- | -| `--github-tokens` | Forward every `GH_TOKEN_*` environment variable into the sandbox so the agent can authenticate `gh`. Off by default. In `--dry-run` mode, token values are printed as `REDACTED`. Use per-command token selection inside the sandbox: `GH_TOKEN="$GH_TOKEN_NSLS2" gh pr list -R NSLS2/repo` | +Requires at least a minimal OpenCode config: `/etc/opencode/opencode.json` or +`opencode.jsonc` (deployed by ansible on managed hosts), or an `opencode.json`, +`opencode.jsonc` or `config.json` in `~/.config/opencode`. Either is enough. +If none exists the wrapper exits with status 2 before touching anything on the +host (`--dry-run` and `--exec` included; `--help` is unaffected). Only the +presence of a readable file is checked, not its content. ### `OPENCODE_*` environment allowlist @@ -254,8 +303,8 @@ variables are printed to stderr. To permit another variable, add it to Launches [OpenCode 2](https://opencode.ai) (`opencode2`, npm `@opencode/cli`) inside a bubblewrap sandbox. It is a separate wrapper from `bwopencode` -because OpenCode 2 uses different directories and has a background service -that must not be reachable from the sandbox. +because OpenCode 2 uses different directories on the host and has a background +service that must not be reachable from the sandbox. ```text bwopencode2 [bwopencode2-options] [opencode2 arguments...] @@ -264,28 +313,30 @@ bwopencode2 [bwopencode2-options] [opencode2 arguments...] Requires the `opencode2` command on `PATH`, which the ansible base role installs. -Accepts the common options above except `--init-auth`, plus `--github-tokens` -(same behavior as `bwopencode`). +Requires at least a minimal OpenCode config, as `bwopencode` does: the +system-wide `/etc/opencode/opencode.json{,c}` (shared with `bwopencode`), or an +`opencode.json`, `opencode.jsonc` or `config.json` in +`~/.config/opencode2/opencode`. If neither exists the wrapper exits with +status 2. The OpenCode 1 config in `~/.config/opencode` is not used. -### Isolation from OpenCode 1 +Accepts the common options above except `--init-auth`. -OpenCode 2 migrates the OpenCode 1 session database one way and cannot load -OpenCode 1 plugins, so it always uses its own directories, matching the -ansible-installed `opencode2` wrapper. Inside the sandbox the wrapper sets -`XDG_CONFIG_HOME`, `XDG_DATA_HOME`, `XDG_STATE_HOME`, and `XDG_CACHE_HOME` to -`~/.config/opencode2`, `~/.local/share/opencode2`, `~/.local/state/opencode2`, -and `~/.cache/opencode2`, even if the `opencode2` on `PATH` is a plain binary. -OpenCode 2 appends `opencode` to each, and the OpenCode 1 directories are never -mounted. - -| Directory | Mount | -| --- | --- | -| `~/.config/opencode2/opencode` | read-only, symlinks resolved | -| `~/.local/share/opencode2/opencode` | read-write, the whole directory persists | -| `~/.cache/opencode2/opencode` | read-write (provider npm packages install here) | -| `~/.local/state/opencode2/opencode` | not persisted (ephemeral) | -| `/.opencode` | persistent copy under the data dir `project-dotfiles/`, so repository content cannot inject config or plugins | -| `/etc/opencode/opencode.json{,c}`, `skills`, `agents`, `commands`, `plugins` | read-only, when present | +### Host directories and sandbox locations + +On the host OpenCode 2 follows the ansible-installed `opencode2` wrapper's +convention of separate `opencode2` directories. Inside the sandbox everything is +mounted at the **standard** locations (the same ones `bwopencode` uses, under +`$XDG_CONFIG_HOME` and so on), so the wrapper sets no `XDG_*` variable and git, +pixi and ccache find their config where the shared library mounts it. + +| Host | Sandbox | Mount | +| --- | --- | --- | +| `~/.config/opencode2/opencode` | `~/.config/opencode` | read-only, symlinks resolved | +| `~/.local/share/opencode2/opencode` | `~/.local/share/opencode` | read-write, the whole directory persists | +| `~/.cache/opencode2/opencode` | `~/.cache/opencode` | read-write (provider npm packages install here) | +| (none) | `~/.local/state/opencode` | not persisted (ephemeral) | +| `/.opencode` | `/.opencode` | persistent copy under the host data dir `project-dotfiles/`, so repository content cannot inject config or plugins | +| `/etc/opencode/opencode.json{,c}`, `skills`, `agents`, `commands`, `plugins` | same | read-only, when present | The whole data directory is bound because the OpenCode 2 layout (database with `-shm`/`-wal`, `log`, `repos`, `shell`, ...) is not documented. Credentials are @@ -294,22 +345,23 @@ persists across sessions without anything like `--init-auth` (OpenCode 2's legacy `auth.json` is only an import source). State is not persisted, so the last-used model and prompt history are not remembered between sessions. -Because `XDG_CONFIG_HOME` and `XDG_CACHE_HOME` point at the opencode2 directories, -the wrapper makes the host's `$XDG_CONFIG_HOME/git`, `pixi/config.toml`, -`ccache` config, and `$XDG_CACHE_HOME/ccache` cache visible at the same relative -location in the opencode2 directories so git, pixi, and ccache keep working. -The git mirror is a symlink to the host-path mounts the shared library already -made, not a second bind: git resolves a relative `[include] path = ../shared` -against the directory of the config path as opened, so a bind at -`~/.config/opencode2/git` would look for `~/.config/opencode2/shared` and -silently skip the include, whereas through the symlink `..` resolves to the real -parent (`~/.config`), where the include is mounted. The symlink is created only -when at least one of the four mounted files (`allowed_signers`, `config`, -`ignore`, `attributes`; see "Credentials and secrets handling") exists, so it -exposes just those files, read-only: `credentials` and anything created on the -host later are not reachable through it. If the host `~/.config/git` is itself a -symlink the library recreates it in the sandbox and the mirror resolves through -it. `~/.gitconfig` and other `$HOME`-relative files are unaffected. +### Config and authoring directories + +A system-wide `/etc/opencode` config is mounted when present, and the user +config in `~/.config/opencode2/opencode` is layered on it, so personal tuning +still works on managed hosts. The `skills`, `agents` and `commands` read-write +authoring directories (see above) live under the host config directory and are +mounted under `~/.config/opencode`; because the host and sandbox paths differ, a +relative symlink inside them cannot resolve and is skipped with a warning +(absolute symlinks work). + +### Isolation from OpenCode 1 + +OpenCode 2 migrates the OpenCode 1 session database one way and cannot load +OpenCode 1 plugins, so on the host it uses its own `opencode2` config, data and +cache directories, and the OpenCode 1 ones are never mounted, even when an +`opencode2` directory does not exist yet (data and cache are created on first +run). ### What is kept out of the sandbox @@ -389,7 +441,22 @@ Additional options: | Option | Effect | | --- | --- | | `--debug` | Enable verbose debug logging: sets `ANTHROPIC_LOG=debug`, `NODE_DEBUG=http,https,tls`, and passes `--debug --verbose` to `claude` | -| `--github-tokens` | Forward every `GH_TOKEN_*` environment variable into the sandbox so the agent can authenticate `gh`. Off by default. In `--dry-run` mode, token values are printed as `REDACTED`. Use per-command token selection inside the sandbox: `GH_TOKEN="$GH_TOKEN_NSLS2" gh pr list -R NSLS2/repo` | + +The Claude home is `~/.claude`, or `$CLAUDE_CONFIG_DIR` when that is set on the +host (it is then also set inside the sandbox, pointing at the same directory). +`bwclaude` persists `logs`, `projects`, `todos`, `statsig`, `file-history`, +`plans` (plan-mode plan files) and `sessions` there, plus `history.jsonl` and +the state file (`~/.claude.json`, or `$CLAUDE_CONFIG_DIR/.claude.json`), which +is shared with unsandboxed `claude`. Other new files under the Claude home are +discarded on exit. The state file is not only UI state: it also holds the OAuth +session (if any), per-project trust decisions and personal MCP server +definitions, so a sandboxed session can change what unsandboxed `claude` trusts +or starts. This is a known gap. + +`settings.json` can define hooks, `apiKeyHelper` and `env` that run outside +the sandbox the next time `claude` is used on the host, so by default the +sandbox only gets a throwaway copy of it. `--init-auth` bind-mounts it +read-write (creating it if needed) along with the credentials. On shared accounts, auth is ephemeral. On a personal machine, run `bwclaude --init-auth` once to persist credentials. @@ -403,12 +470,6 @@ inside a bubblewrap sandbox. bwcopilot [bwcopilot-options] [copilot arguments...] ``` -Additional options: - -| Option | Effect | -| --- | --- | -| `--github-tokens` | Forward every `GH_TOKEN_*` environment variable into the sandbox so the agent can authenticate `gh`. Off by default. In `--dry-run` mode, token values are printed as `REDACTED`. Use per-command token selection inside the sandbox: `GH_TOKEN="$GH_TOKEN_NSLS2" gh pr list -R NSLS2/repo` | - On shared accounts, auth tokens are ephemeral. On a personal machine, run `bwcopilot --init-auth` once to persist tokens. @@ -446,13 +507,18 @@ Two approved auth paths (configured in `~/.codex/config.toml`): tokens to `~/.codex/auth.json`. Run once with `--init-auth` to make that file persist across sandbox invocations. +The wrapper keeps `~/.codex/packages` in its ephemeral Codex home. When the +daemon starts, Codex copies its package from the system CLI, which the wrapper +mounts read-only with its metadata and resources. An ephemeral +`app-server-daemon/settings.json` disables automatic daemon package updates. +The package copy is discarded when the sandbox exits. + Additional options: | Option | Effect | | --- | --- | | `--debug` | Enable verbose Codex logging (sets `RUST_LOG=debug`) | | `--persist-config` | Bind-mount `~/.codex/config.toml` read-write into the sandbox so changes (e.g. project-trust grants) persist across sessions. Without this flag, `config.toml` is staged read-write into a per-session copy that is discarded on exit. Concurrent `--persist-config` sessions may race on writes | -| `--github-tokens` | Forward every `GH_TOKEN_*` environment variable into the sandbox so the agent can authenticate `gh`. Off by default. In `--dry-run` mode, token values are printed as `REDACTED`. Use per-command token selection inside the sandbox: `GH_TOKEN="$GH_TOKEN_NSLS2" gh pr list -R NSLS2/repo` | Note: Codex CLI also enforces its own inner Landlock-based sandbox for agent tool calls. The outer `bwrap` here is complementary — it scopes diff --git a/docs/gh-protect-branch.md b/docs/gh-protect-branch.md index 9b52041..4501cb1 100644 --- a/docs/gh-protect-branch.md +++ b/docs/gh-protect-branch.md @@ -87,7 +87,7 @@ gh-protect-branch --help gh-protect-branch NSLS2/n2snscripts # Standard protection on a non-default branch -gh-protect-branch NSLS2/n2sndocs main +gh-protect-branch NSLS2/n2sndocs develop # With approver team restriction (team validated before applying) gh-protect-branch --approvers n2sn-admins NSLS2/n2snscripts diff --git a/docs/gpg-passwd.md b/docs/gpg-passwd.md index b26c5b7..5a55af3 100644 --- a/docs/gpg-passwd.md +++ b/docs/gpg-passwd.md @@ -4,6 +4,12 @@ Shell library providing `decrypt_env_file`, a helper for sourcing GPG-encrypted environment files into the current shell. Sourced, not executed. +**Works in both bash and zsh**, so it can be sourced from a `~/.bashrc` or a +`~/.zshrc`. The required-variable check reads each variable by `eval` +indirection rather than a bash-only `local -n` nameref; the name is validated +first (see [Required-variable names](#required-variable-names)), so the `eval` +cannot inject code. + ## Usage Source the library and call `decrypt_env_file`: @@ -13,6 +19,29 @@ source "${N2SNSCRIPTS_LIB}/gpg-passwd.sh" decrypt_env_file "$HOME/.private_env.gpg" AIFAPIM_HOST AIFAPIM_API_KEY ``` +### Calling under `set -e` + +`decrypt_env_file` runs `gpg` inside a command substitution. Under a +caller's `set -e`, a non-zero exit from that substitution can abort the +caller *before* it reads the return code, so the return values below are only +reliably observed when the call is guarded. Always capture the status: + +```bash +rc=0 +decrypt_env_file "$HOME/.private_env.gpg" AIFAPIM_HOST AIFAPIM_API_KEY || rc=$? +# inspect $rc: 0 = ok/missing-file, 2 = required var empty / bad args, +# 3 = gpg failed +``` + +The trailing `|| rc=$?` both captures the code and suppresses `set -e`. + +### Required-variable names + +Required-variable names passed to `decrypt_env_file` must be plain shell +identifiers (letters, digits and underscores, not starting with a digit) and +must not begin with `__gpgpw_`; that prefix is reserved for the function's own +locals. Any other name is rejected with return code `2`. + ## Behaviour - Decrypts the file with `gpg --quiet --batch --yes --decrypt` and @@ -26,7 +55,8 @@ decrypt_env_file "$HOME/.private_env.gpg" AIFAPIM_HOST AIFAPIM_API_KEY | --- | --- | | `0` | Success | | `0` (with warning) | File is missing — no decrypt attempted | -| `2` | A required variable is still empty after decrypt | +| `2` | A required variable is still empty after decrypt, or a required-variable name is invalid or reserved | | `3` | `gpg` failed (bad passphrase, malformed file, etc.) | +| `4` | `eval` of decrypted content failed (syntax error or failing command) | See the library header comment for the full contract. diff --git a/docs/pemdecompose.md b/docs/pemdecompose.md index 8f0afce..0182fbe 100644 --- a/docs/pemdecompose.md +++ b/docs/pemdecompose.md @@ -24,7 +24,7 @@ pemdecompose --help | --- | --- | | `--verify` | Verify each cert is issued by the next cert in the file (signature + issuer/subject DN match) | | `-q`, `--quiet` | Suppress the per-file banner and inter-file blank line when multiple files are given | -| `--no-color` | Disable ANSI colour output (also disabled automatically when stdout is not a TTY or `NO_COLOR` is set) | +| `--no-color`, `--no-colour` | Disable ANSI colour output (also disabled automatically when stdout is not a TTY or `NO_COLOR` is set) | | `-h`, `--help` | Show help and exit | ## Exit codes @@ -32,10 +32,11 @@ pemdecompose --help | Code | Meaning | | --- | --- | | `0` | Success | -| `1` | Usage error | +| `1` | Usage error (bad option, no input file, or an unreadable input file) | | `2` | No certificates found in at least one input file | | `3` | `openssl` parse error on at least one certificate | | `4` | `--verify` failed for at least one chain link | +| `5` | Operational error (e.g. cannot create the temporary directory) | ## Examples diff --git a/lib/bwrap_sandbox_lib.sh b/lib/bwrap_sandbox_lib.sh index 216b375..6366971 100644 --- a/lib/bwrap_sandbox_lib.sh +++ b/lib/bwrap_sandbox_lib.sh @@ -29,7 +29,6 @@ # # Requires: bwrap >= 0.4.0 (RHEL 8) # - 0.4.0: base functionality -# - 0.5.0+: --clearenv (fallback: manual --unsetenv for each host var) # - 0.6.3+: bind over ro-bind (fallback: skip binary masking) # Guard against double-sourcing. @@ -78,9 +77,13 @@ _NPM_PREFIX_RESOLVED=0 # Sandbox environment SANDBOX_PATH="" SANDBOX_SHELL="" +# NAME=VALUE entries for variables forwarded from the host (pass_through_if_set, +# forward_env_by_prefix) and other values that may be secret. They are given +# to bwrap through `env -i` (see launch_sandbox) rather than as --setenv +# arguments, so they never appear on bwrap's command line. +SANDBOX_ENV=() # bwrap capability flags (set by detect_bwrap_capabilities) -HAS_CLEARENV=0 HAS_BIND_OVER_RO=0 # Kernel capability flags (set by detect_kernel_capabilities) @@ -143,15 +146,21 @@ declare -A _GIT_LINK_STATUS=() # sandbox as a plain directory (see _bwrap_git_recreate_link). declare -A _GIT_LINK_CONFLICT=() -# Set to 1 by build_git_mounts when at least one file of -# ${XDG_CONFIG_HOME}/git was mounted (launchers may point symlinks at the -# directory; it is only guaranteed to resolve in the sandbox in that case). -_BWRAP_XDG_GIT_HAS_FILES=0 - # Working directory state (set by build_workdir_mount) _BIND_DIR="" _GIT_ROOT="" +# Common paths (set by resolve_common_paths). _HOME is the logical $HOME; +# _HOME_CANON is its readlink -f form. They differ when home is reached +# through a symlink (NFS/automount), and the $HOME-or-ancestor check must +# consult both because candidate paths are canonicalized before it runs. +# _PWD is the physical (pwd -P) working directory. _USER is $USER, which can +# be unset under `set -u` (cron/CI), with a fallback to bwrap_account_name. +_HOME="" +_HOME_CANON="" +_PWD="" +_USER="" + # ═══════════════════════════════════════════════════════════════════ # Shared helper functions # ═══════════════════════════════════════════════════════════════════ @@ -172,18 +181,12 @@ _version_ge() { # grepping --help. # # Version history: -# 0.5.0 - added --clearenv # 0.6.3 - bind over ro-bind works (can mask binaries inside /usr) detect_bwrap_capabilities() { local _bwrap_ver _bwrap_ver="$(bwrap --version)" _bwrap_ver="${_bwrap_ver##* }" # "bubblewrap 0.11.0" -> "0.11.0" - # --clearenv: Start with an empty environment (added in 0.5.0) - # Fallback: manually unset all host env vars with --unsetenv - HAS_CLEARENV=0 - if _version_ge "${_bwrap_ver}" "0.5.0"; then HAS_CLEARENV=1; fi - # Bind over ro-bind: ability to bind-mount on top of a read-only bind # mount (e.g., masking /usr/bin/ssh after --ro-bind /usr /usr). # This works in 0.6.3+; earlier versions fail with "Permission denied". @@ -509,6 +512,22 @@ _bwrap_symlink_is_restricted() { done } +# bwrap_show_tool_help NAME +# Finish a launcher's --help after its own text has been printed. When the +# tool NAME is usable, re-exec the launcher as `--exec "NAME --help"` so the +# tool's own help comes from inside the fully built sandbox. Otherwise say +# so and exit 0: the wrapper help must not depend on the tool being +# installed. Never returns. +bwrap_show_tool_help() { + local _name="$1" _bin + _bin="$(bwrap_find_tool "${_name}" 2> /dev/null || true)" + if [[ -n "${_bin}" && -x "${_bin}" ]]; then + exec "$0" --exec "${_bin} --help" + fi + echo "('${_name}' is not available here, so its own --help is not shown.)" + exit 0 +} + # bwrap_resolve_tool NAME [NOT_FOUND_HINT] # Resolve the agent binary NAME for a launcher, setting _TOOL_BIN and # _TOOL_CMD (TEST_CMD, split into words, overrides the command). When NAME @@ -568,23 +587,76 @@ bwrap_resolve_tool() { exit 1 } +# _persist_bind_wanted PATH +# True if PATH exists, or under --dry-run --init-auth: there a real run +# would have created the stub file and bound it, so the bind is emitted for +# an accurate dry-run even though the stub was not created. +_persist_bind_wanted() { + [[ -e "$1" ]] || { [[ "${INIT_AUTH:-0}" -eq 1 ]] && [[ "${DRY_RUN}" -eq 1 ]]; } +} + +# _expand_leading_tilde VALUE +# Print VALUE with only a LEADING tilde expanded: +# "~" / "~/path" -> $HOME / $HOME/path +# "~user" / "~user/p" -> that user's home (getent passwd) [+ /p] +# anything else -> VALUE unchanged +# Returns 1 (printing nothing) when a ~user reference cannot be resolved. +# A blind ${VALUE/#\~/${_HOME}} substitution turns "~alice/foo" into +# "${HOME}alice/foo", silently corrupting the path. +_expand_leading_tilde() { + local value="$1" _rest _user _subpath="" _uhome + # The patterns match a literal leading tilde (SC2088 false positive). + # shellcheck disable=SC2088 + case "${value}" in + "~") + printf '%s' "${_HOME}" + ;; + "~/"*) + printf '%s' "${_HOME}/${value#\~/}" + ;; + "~"*) + _rest="${value#\~}" + _user="${_rest%%/*}" + [[ "${_rest}" == */* ]] && _subpath="/${_rest#*/}" + _uhome="$(getent passwd "${_user}" 2> /dev/null | cut -d: -f6)" + [[ -n "${_uhome}" ]] || return 1 + printf '%s' "${_uhome}${_subpath}" + ;; + *) + printf '%s' "${value}" + ;; + esac +} + # ── Dynamic mount helpers ──────────────────────────────────────── # _MOUNTED_PREFIXES: tracks directory trees already covered by a bwrap # bind so we never emit duplicate (or redundant sub-path) mounts. # Keys are canonical paths; value is always 1. # Pre-populated with unconditional mounts in build_dynamic_tool_mounts. -# _get_npm_prefix -# Echo the npm global prefix (`npm prefix -g`), caching the result so the -# subprocess is forked at most once per run even when several symlinked -# tools are resolved. Echoes the empty string if npm is not installed or -# the lookup fails. -_get_npm_prefix() { - if [[ "${_NPM_PREFIX_RESOLVED}" -eq 0 ]]; then +# _resolve_npm_prefix +# Set _NPM_PREFIX to the npm global prefix (`npm prefix -g`), forking npm +# at most once per run; empty if npm is absent or the lookup fails. +# Must NOT be called inside $(...): the cache globals would be set in the +# subshell and lost, so npm would run again on every lookup. Callers invoke +# it directly and then read ${_NPM_PREFIX}. +# `npm prefix -g` writes a log under $npm_config_cache/_logs (default +# ~/.npm/_logs); the cache is pointed at a throwaway directory so no run, +# including --dry-run, leaves that behind on the host. +_resolve_npm_prefix() { + [[ "${_NPM_PREFIX_RESOLVED}" -eq 1 ]] && return 0 + _NPM_PREFIX_RESOLVED=1 + _NPM_PREFIX="" + command -v npm > /dev/null 2>&1 || return 0 + + local _npm_tmp + _npm_tmp="$(mktemp -d 2> /dev/null || true)" + if [[ -n "${_npm_tmp}" ]]; then + _NPM_PREFIX="$(npm_config_cache="${_npm_tmp}" npm prefix -g 2> /dev/null || true)" + rm -rf -- "${_npm_tmp}" + else _NPM_PREFIX="$(npm prefix -g 2> /dev/null || true)" - _NPM_PREFIX_RESOLVED=1 fi - printf '%s' "${_NPM_PREFIX}" } # _emit_home_intermediate_dirs DIR [MODE] @@ -602,9 +674,18 @@ _get_npm_prefix() { # file bind, or a read-write directory bind point). _emit_home_intermediate_dirs() { local dir="$1" mode="${2:-ancestors}" - [[ "${dir}" == "${_HOME}"/* ]] || return 0 - local _rel="${dir#"${_HOME}"/}" - local _accum="${_HOME}" + # A canonicalized bind target can sit under _HOME_CANON rather than the + # logical _HOME when home is symlinked; match against both. + local _home_base + if [[ "${dir}" == "${_HOME}"/* ]]; then + _home_base="${_HOME}" + elif [[ -n "${_HOME_CANON}" && "${dir}" == "${_HOME_CANON}"/* ]]; then + _home_base="${_HOME_CANON}" + else + return 0 + fi + local _rel="${dir#"${_home_base}"/}" + local _accum="${_home_base}" local _parts _i _last IFS='/' read -ra _parts <<< "${_rel}" if [[ "${mode}" == "inclusive" ]]; then @@ -624,9 +705,10 @@ _emit_home_intermediate_dirs() { # actually emitted, DIR is added to _MOUNTED_PREFIXES so future calls # with the same path or a sub-path are no-ops. # Handles intermediate --dir entries for paths under $HOME. -# Calls _check_path_safe before emitting any mount — aborts if DIR +# Calls _check_bind_safe before emitting any mount — aborts if DIR # resolves into a blocked path (e.g. a tool binary that symlinks into -# ~/.ssh or a misconfigured npm prefix pointing at $HOME). +# ~/.ssh or a misconfigured npm prefix pointing at $HOME) or contains one +# (e.g. an on-PATH ~/.config holding ~/.config/gh). _mount_ro_dir_if_needed() { local dir="$1" [[ -d "${dir}" ]] || return 0 @@ -644,10 +726,14 @@ _mount_ro_dir_if_needed() { [[ -z "${check}" ]] && check="/" done - # Safety check before emitting the mount. DIR is already a real - # directory path (callers pass dirname() of a resolved binary or a - # known path), so no additional readlink -f is needed. - _check_path_safe "${dir}" "dynamic mount" + # Safety check before emitting the mount, judged on the canonical target: + # DIR can be a symlinked on-PATH directory (e.g. ~/bin2 -> ~/.ssh) and + # bwrap follows the symlink when binding. DIR itself is still the mount + # point, so the tool stays reachable at the path it is invoked by. + local _dir_canon + _dir_canon="$(readlink -f -- "${dir}" 2> /dev/null || true)" + [[ -n "${_dir_canon}" ]] || _dir_canon="${dir}" + _check_bind_safe "${_dir_canon}" "dynamic mount" # Create intermediate --dir entries for paths under $HOME (the # tmpfs $HOME has no subdirectories yet). DIR is the bind mount point, @@ -680,9 +766,14 @@ resolve_and_mount_tool() { if [[ -L "${bin_path}" ]]; then real_path="$(readlink -f "${bin_path}")" - npm_prefix="$(_get_npm_prefix)" + _resolve_npm_prefix + npm_prefix="${_NPM_PREFIX}" if [[ -n "${npm_prefix}" ]] && [[ "${real_path}" == "${npm_prefix}"/* ]]; then - # npm-installed: mount the whole prefix so lib/node_modules is reachable. + # npm-installed: mount the whole prefix so lib/node_modules is + # reachable. Also mount the on-PATH directory holding the symlink + # (e.g. ~/.local/bin/claude -> npm prefix): otherwise the path the + # tool is invoked by does not exist inside the sandbox. + _mount_ro_dir_if_needed "${cmd_dir}" _mount_npm_global_prefix "${npm_prefix}" return 0 fi @@ -705,7 +796,8 @@ resolve_and_mount_tool() { _mount_npm_global_prefix() { local npm_prefix="${1:-}" if [[ -z "${npm_prefix}" ]]; then - npm_prefix="$(_get_npm_prefix)" + _resolve_npm_prefix + npm_prefix="${_NPM_PREFIX}" fi [[ -n "${npm_prefix}" ]] && [[ -d "${npm_prefix}" ]] || return 0 @@ -713,7 +805,7 @@ _mount_npm_global_prefix() { # are resolved (npm itself may return a non-canonical path on some systems). local npm_prefix_canon npm_prefix_canon="$(readlink -f "${npm_prefix}" 2> /dev/null || echo "${npm_prefix}")" - _check_path_safe "${npm_prefix_canon}" "npm global prefix" + _check_bind_safe "${npm_prefix_canon}" "npm global prefix" _mount_ro_dir_if_needed "${npm_prefix_canon}" _EXTRA_PATH_DIRS+=("${npm_prefix_canon}/bin") @@ -791,7 +883,26 @@ parse_git_includes() { # Extract path values from [include] and [includeIf "..."] sections. # Matches lines like: path = /some/path or path = ~/relative # Pure bash — no grep/sed/xargs subprocesses. + # + # `path` is only an include directive inside an include section: git's + # parser is section-scoped, and a `path =` key elsewhere (for example + # [difftool "x"] path = ...) must not be mounted, nor abort the launcher + # when it points into a blocked location. Section names are + # case-insensitive; a key may follow the header on the same line. + local _section_re='^[[:space:]]*\[[[:space:]]*([A-Za-z0-9.-]+)([[:space:]]+"([^"\\]|\\.)*")?[[:space:]]*\](.*)$' + local _section_name in_include=0 while IFS= read -r line || [[ -n "${line}" ]]; do + if [[ "${line}" =~ ${_section_re} ]]; then + _section_name="${BASH_REMATCH[1],,}" + line="${BASH_REMATCH[4]}" + if [[ "${_section_name}" == "include" || "${_section_name}" == "includeif" ]]; then + in_include=1 + else + in_include=0 + fi + fi + [[ "${in_include}" -eq 1 ]] || continue + # git variable names are case-insensitive. The value is decoded the # way git does (quotes, escapes, trailing comments) before anything is # derived from it; an undecodable value is skipped. @@ -800,8 +911,9 @@ parse_git_includes() { local path_val="${_BWRAP_GIT_VALUE}" [[ -z "${path_val}" ]] && continue - # Resolve ~/ prefix - path_val="${path_val/#\~/${_HOME}}" + # Expand a leading ~ or ~user (git does both); skip the include when + # ~user cannot be resolved rather than corrupting the path. + path_val="$(_expand_leading_tilde "${path_val}")" || continue # Resolve relative paths (relative to the config file's directory) if [[ "${path_val}" != /* ]]; then @@ -856,14 +968,36 @@ parse_all_git_includes() { # ── Env var passthrough helper ─────────────────────────────────── +# pass_through_if_set NAME +# Forward the host variable NAME into the sandbox when it is non-empty. The +# value goes into SANDBOX_ENV (bwrap's environment), not onto bwrap's command +# line. A later `--setenv NAME` in BWRAP_ARGS still overrides it. pass_through_if_set() { local var_name="$1" local var_val="${!var_name:-}" if [[ -n "${var_val}" ]]; then - BWRAP_ARGS+=(--setenv "${var_name}" "${var_val}") + SANDBOX_ENV+=("${var_name}=${var_val}") fi } +# forward_env_by_prefix PREFIX +# Forward every exported environment variable whose name starts with PREFIX +# (as NAME=VALUE entries in SANDBOX_ENV, like pass_through_if_set). Uses +# `while IFS= read -r` over `compgen -e`, so names are never word-split. +# Values are passed as-is; print_dry_run redacts names that match +# _is_secret_env_name. +# +# Example: +# forward_env_by_prefix GH_TOKEN_ # GH_TOKEN_NSLS2, GH_TOKEN_PERSONAL, ... +forward_env_by_prefix() { + local _prefix="$1" _key + while IFS= read -r _key; do + if [[ "${_key}" == "${_prefix}"* ]]; then + SANDBOX_ENV+=("${_key}=${!_key}") + fi + done < <(compgen -e) +} + # ── Path safety validation ─────────────────────────────────────── # _is_prefix_of A B @@ -953,8 +1087,11 @@ _bwrap_path_blocked() { # Reject if canon IS $HOME or is a directory that contains $HOME # (i.e. /, /home, the username's parent dir, etc.). - # "_is_prefix_of canon _HOME" means: canon is a prefix of _HOME. - if _is_prefix_of "${canon}" "${_HOME}"; then + # "_is_prefix_of canon _HOME" means: canon is a prefix of _HOME. The + # canonical home is checked too: with a symlinked home (/home/u -> + # /nfs/home/u) a canonical candidate /nfs/home/u is not a prefix of _HOME. + if _is_prefix_of "${canon}" "${_HOME}" || + { [[ -n "${_HOME_CANON}" ]] && _is_prefix_of "${canon}" "${_HOME_CANON}"; }; then _BWRAP_BLOCKED_KIND="home" return 0 fi @@ -975,6 +1112,10 @@ _bwrap_path_blocked() { # Assert that the already-resolved canonical path CANON is safe to mount # into the sandbox. Exits 1 with a descriptive error if it is not. # +# This only asks whether CANON is itself blocked. Callers that bind a path +# into the sandbox should use _check_bind_safe, which also refuses a +# directory that CONTAINS a blocked location. +# # CANON : absolute, symlink-free path (output of readlink -f). # CONTEXT : short label used in error messages, e.g. "--ro-path", # "--rw-path", or "working directory". @@ -1052,28 +1193,57 @@ _check_path_safe() { # deliberately never mounted. Existing paths only. EXTRA_BLOCKED_DIRS=() -# _check_dir_contains_blocked CANON CONTEXT -# Companion of _check_path_safe for a directory that is bound WHOLE (working -# directory or git root, --ro-path, --rw-path): exit 1 when the canonical -# directory CANON is, or is an ancestor of, a blocked location that exists -# on disk (anything in the blocked list, a symlink's target included, or an -# EXTRA_BLOCKED_DIRS entry). Binding such a directory would expose the -# blocked location through the parent mount (e.g. running from ~/.config -# would bind ~/.config/gh read-write). Blocked locations that do not exist -# are ignored. Call AFTER _check_path_safe (which reports $HOME, its -# ancestors and paths inside a blocked tree first). -_check_dir_contains_blocked() { - local canon="$1" context="$2" _b _t +# _bwrap_contains_blocked CANON +# Return 0 (true) when the canonical path CANON is, or is an ancestor of, a +# blocked location that exists on disk (anything in the blocked list, a +# symlink's target included, or an EXTRA_BLOCKED_DIRS entry); the offending +# entry is left in _BWRAP_CONTAINED_BLOCKED. Blocked locations that do not +# exist are ignored. Binding such a directory would expose the blocked +# location through the parent mount (e.g. running from ~/.config would bind +# ~/.config/gh read-write). Does NOT check whether CANON itself is inside a +# blocked tree: use _bwrap_bind_blocked or _check_bind_safe for that. +_bwrap_contains_blocked() { + local canon="$1" _b _t + _BWRAP_CONTAINED_BLOCKED="" _bwrap_load_blocked_prefixes for _b in "${_BWRAP_BLOCKED_LIST[@]}" "${EXTRA_BLOCKED_DIRS[@]+"${EXTRA_BLOCKED_DIRS[@]}"}"; do [[ -n "${_b}" ]] || continue [[ -e "${_b}" || -L "${_b}" ]] || continue _t="$(readlink -f -- "${_b}" 2> /dev/null || true)" if _is_prefix_of "${canon}" "${_b}" || { [[ -n "${_t}" ]] && _is_prefix_of "${canon}" "${_t}"; }; then - echo "Error: ${context} '${canon}' contains the blocked path '${_b}'. Refusing to mount." >&2 - exit 1 + _BWRAP_CONTAINED_BLOCKED="${_b}" + return 0 fi done + return 1 +} + +# _bwrap_bind_blocked CANON +# Non-exiting safety check for a path about to be bind-mounted: return 0 +# (true) when CANON is blocked (_bwrap_path_blocked) OR contains a blocked +# location (_bwrap_contains_blocked). Use where an unsafe candidate is +# skipped with a warning instead of aborting the launcher. +_bwrap_bind_blocked() { + _bwrap_path_blocked "$1" || _bwrap_contains_blocked "$1" +} + +# _check_bind_safe CANON CONTEXT +# The safety check for ANY path about to be bind-mounted: exit 1 with an +# error when CANON is blocked (see _check_path_safe) or, for a directory +# bound whole, contains a blocked location. Use this at every bind-mount +# site rather than _check_path_safe alone, which does not notice a +# directory that contains a blocked path. +# +# _check_path_safe on its own is for paths that are validated but not bound +# whole: a tool-home directory that only has selected subpaths mounted +# (see _check_dir_safe), or a symlink target about to be resolved. +_check_bind_safe() { + local canon="$1" context="$2" + _check_path_safe "${canon}" "${context}" + if _bwrap_contains_blocked "${canon}"; then + echo "Error: ${context} '${canon}' contains the blocked path '${_BWRAP_CONTAINED_BLOCKED}'. Refusing to mount." >&2 + exit 1 + fi } # _check_dir_safe PATH CONTEXT @@ -1102,20 +1272,29 @@ _check_dir_safe() { # _bwrap_canonicalize_safe PATH CONTEXT # Canonicalize PATH with readlink -f (falls back to PATH itself when -# readlink fails or PATH does not exist) and run _check_path_safe on the +# readlink fails or PATH does not exist) and run _check_bind_safe on the # canonical result. ALWAYS canonicalizes, so a symlinked parent directory # cannot hide a blocked target (bwrap's --ro-bind follows the symlink and # would mount the real file). # +# Pass "root" as MODE for a directory that is validated but NOT bound whole +# (a tool-home directory that only has selected subpaths mounted): then only +# _check_path_safe runs, as a contained blocked location is not exposed. +# # The canonical path is returned in the global _BWRAP_CANON, NOT on stdout: -# _check_path_safe calls exit 1, and inside $(...) that would only leave -# the subshell and let the launcher carry on. +# the checks call exit 1, and inside $(...) that would only leave the +# subshell and let the launcher carry on. _bwrap_canonicalize_safe() { local path="$1" local context="$2" + local mode="${3:-bind}" _BWRAP_CANON="$(readlink -f "${path}" 2> /dev/null || true)" [[ -n "${_BWRAP_CANON}" ]] || _BWRAP_CANON="${path}" - _check_path_safe "${_BWRAP_CANON}" "${context}" + if [[ "${mode}" == "root" ]]; then + _check_path_safe "${_BWRAP_CANON}" "${context}" + else + _check_bind_safe "${_BWRAP_CANON}" "${context}" + fi } # _bwrap_include_visible_path RAW @@ -1216,15 +1395,20 @@ _bwrap_include_lexical_path() { # On failure: prints an error to stderr and exits 1. # # Steps: -# 1. Expand leading ~/ then resolve to canonical path via readlink -f. +# 1. Expand a leading ~ or ~user, then resolve to a canonical path via +# readlink -f. # 2. Verify the path exists on the host. # 3. Delegate safety checks to _check_path_safe. validate_extra_path() { local raw_path="$1" local mode="$2" - # Expand a leading ~/ to $HOME (readlink -f won't do this for us). - local expanded_path="${raw_path/#\~/${_HOME}}" + # Expand a leading ~ or ~user (readlink -f won't do this for us). + local expanded_path + if ! expanded_path="$(_expand_leading_tilde "${raw_path}")"; then + echo "Error: --${mode}-path '${raw_path}': cannot resolve the '~user' home directory." >&2 + exit 1 + fi # Resolve to canonical path (follows symlinks, removes . and ..). local canon @@ -1237,10 +1421,7 @@ validate_extra_path() { fi # ── Safety check ───────────────────────────────────────────── - _check_path_safe "${canon}" "--${mode}-path" - if [[ -d "${canon}" ]]; then - _check_dir_contains_blocked "${canon}" "--${mode}-path" - fi + _check_bind_safe "${canon}" "--${mode}-path" # All checks passed — return the canonical path. echo "${canon}" @@ -1252,53 +1433,111 @@ validate_extra_path() { # Mount all paths accumulated in EXTRA_RO_PATHS and EXTRA_RW_PATHS. # Each path is validated by validate_extra_path before mounting. # -# Read-only paths: use _mount_ro_dir_if_needed for directories -# (deduplication via _MOUNTED_PREFIXES); for files, emit --ro-bind -# directly (no dedup needed — an individual file bind never subsumes -# a directory tree). +# Binds are emitted ANCESTORS-FIRST across both modes: bwrap applies binds +# in order and the last one covering a mount point wins, so a path nested +# under another is emitted after its parent and overrides it. This lets +# --rw-path DIR --ro-path DIR/sub +# keep DIR writable but DIR/sub read-only (and the reverse: a read-only +# tree with one writable subdirectory). A path given as both --ro-path and +# --rw-path is rejected. # -# Read-write paths: emit --bind directly for both files and directories. -# For paths under $HOME, intermediate --dir entries are created first -# so the bind mount point exists in the tmpfs. +# User paths are emitted DIRECTLY, not through the _MOUNTED_PREFIXES dedup +# used for automatic mounts: that dedup would skip a child "covered" by a +# parent bind (defeating the nesting, or a read-only subdirectory of the +# read-write working directory), and would silently drop any path under a +# pre-registered prefix such as /tmp, which is a tmpfs in the sandbox. # -# Must be called AFTER build_dynamic_tool_mounts (which pre-populates -# _MOUNTED_PREFIXES) and AFTER build_home_tmpfs (which creates the -# $HOME tmpfs that intermediate --dir entries populate). +# Must be called AFTER the automatic mount steps (working directory, tools, +# system, home) so an explicit path overrides them, and AFTER +# build_home_tmpfs (which creates the $HOME tmpfs that intermediate --dir +# entries populate). build_extra_path_mounts() { - local _raw_path _canon _target + # Guarded expansion (not ${#arr[@]}): empty-array expansions are fragile + # under `set -u` on bash < 4.4. + [[ -z "${EXTRA_RO_PATHS[*]+x}${EXTRA_RW_PATHS[*]+x}" ]] && return 0 - # ── Read-only extra paths ──────────────────────────────────── - for _raw_path in "${EXTRA_RO_PATHS[@]+"${EXTRA_RO_PATHS[@]}"}"; do - _canon="$(validate_extra_path "${_raw_path}" "ro")" + local _raw_path _canon _mode + local -A _mode_of=() - if [[ -d "${_canon}" ]]; then - # Directories: go through the dedup helper. - _mount_ro_dir_if_needed "${_canon}" - else - # Files: --ro-bind directly. Pre-create the file's parent - # directory chain if it lives under $HOME (the tmpfs has no - # subdirs yet); the file itself is the bind mount point. - _emit_home_intermediate_dirs "${_canon%/*}" inclusive - BWRAP_ARGS+=(--ro-bind "${_canon}" "${_canon}") + # Validate everything up front. validate_extra_path runs in a command + # substitution, where its `exit 1` only leaves the subshell, so the + # failure is propagated explicitly. + for _raw_path in "${EXTRA_RO_PATHS[@]+"${EXTRA_RO_PATHS[@]}"}"; do + _canon="$(validate_extra_path "${_raw_path}" "ro")" || exit 1 + _mode_of["${_canon}"]="ro" + done + for _raw_path in "${EXTRA_RW_PATHS[@]+"${EXTRA_RW_PATHS[@]}"}"; do + _canon="$(validate_extra_path "${_raw_path}" "rw")" || exit 1 + if [[ "${_mode_of["${_canon}"]:-}" == "ro" ]]; then + echo "Error: '${_canon}' was given as both --ro-path and --rw-path." >&2 + exit 1 fi + _mode_of["${_canon}"]="rw" done - # ── Read-write extra paths ─────────────────────────────────── - for _raw_path in "${EXTRA_RW_PATHS[@]+"${EXTRA_RW_PATHS[@]}"}"; do - _canon="$(validate_extra_path "${_raw_path}" "rw")" - - # Pre-create the intermediate --dir chain when the path is under - # $HOME. For a directory the chain includes the target itself (the - # subsequent --bind lands on top of that --dir); for a file the - # chain stops at the parent directory. - if [[ -d "${_canon}" ]]; then - _target="${_canon}" - else - _target="${_canon%/*}" + # Ancestors first: an ancestor is a byte prefix of its descendants, so a + # plain LC_ALL=C sort orders it before them. NUL-delimited end to end so a + # path containing a newline stays one entry (mapfile -d needs bash 4.4, so + # read in a loop). + local -a _sorted=() + local _sorted_path + while IFS= read -r -d '' _sorted_path; do + _sorted+=("${_sorted_path}") + done < <(printf '%s\0' "${!_mode_of[@]}" | LC_ALL=C sort -z) + + # Destinations of every bind emitted so far. A path with a bound + # ancestor needs no intermediate --dir entries (the ancestor's host tree + # already contains it), and a --dir there would clobber that bind. + local -A _bound_dests=() + local _bi=0 _bflag + while [[ ${_bi} -lt ${#BWRAP_ARGS[@]} ]]; do + _bflag="${BWRAP_ARGS[${_bi}]}" + _bwrap_flag_arity "${_bflag}" + case "${_bflag}" in + --bind | --bind-try | --ro-bind | --ro-bind-try | --dev-bind | --dev-bind-try) + _bound_dests["${BWRAP_ARGS[$((_bi + 2))]}"]=1 + ;; + esac + _bi=$((_bi + 1 + _BWRAP_FLAG_ARITY)) + done + + local _anc _has_bound_ancestor + for _canon in "${_sorted[@]}"; do + _mode="${_mode_of["${_canon}"]}" + + _has_bound_ancestor=0 + _anc="${_canon%/*}" + [[ -n "${_anc}" ]] || _anc="/" + while :; do + if [[ -n "${_bound_dests["${_anc}"]:-}" ]]; then + _has_bound_ancestor=1 + break + fi + [[ "${_anc}" == "/" ]] && break + _anc="${_anc%/*}" + [[ -n "${_anc}" ]] || _anc="/" + done + + # Pre-create the intermediate --dir chain under $HOME. A read-write + # directory includes the target itself (the --bind lands on top of + # that --dir); a read-only directory is its own mount point; a file + # needs its parent chain. + if [[ ${_has_bound_ancestor} -eq 0 ]]; then + if [[ -d "${_canon}" && "${_mode}" == "rw" ]]; then + _emit_home_intermediate_dirs "${_canon}" inclusive + elif [[ -d "${_canon}" ]]; then + _emit_home_intermediate_dirs "${_canon}" + else + _emit_home_intermediate_dirs "${_canon%/*}" inclusive + fi fi - _emit_home_intermediate_dirs "${_target}" inclusive - BWRAP_ARGS+=(--bind "${_canon}" "${_canon}") + if [[ "${_mode}" == "ro" ]]; then + BWRAP_ARGS+=(--ro-bind "${_canon}" "${_canon}") + else + BWRAP_ARGS+=(--bind "${_canon}" "${_canon}") + fi + _bound_dests["${_canon}"]=1 done } @@ -1306,7 +1545,14 @@ build_extra_path_mounts() { resolve_common_paths() { _HOME="${HOME}" - _PWD="${PWD}" + _HOME_CANON="$(readlink -f -- "${HOME}" 2> /dev/null || true)" + [[ -n "${_HOME_CANON}" ]] || _HOME_CANON="${HOME}" + # Physical working directory, so PWD in the sandbox agrees with the + # canonical working-directory bind (git rev-parse also returns physical + # paths). + _PWD="$(pwd -P 2> /dev/null || true)" + [[ -n "${_PWD}" ]] || _PWD="${PWD}" + _USER="${USER:-$(bwrap_account_name)}" # XDG defaults XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-${_HOME}/.config}" @@ -1366,9 +1612,23 @@ init_sandbox_path() { } # Append dynamically-discovered bin directories to SANDBOX_PATH. +# Deduplicates: the same dir can be recorded more than once (e.g. an npm bin +# dir added both as a tool's on-PATH dir and as the npm prefix's bin/), and a +# dir already present in the base SANDBOX_PATH need not be appended again. finalize_sandbox_path() { local _extra_dir + declare -A _path_seen=() + # Seed with the base PATH entries so we don't re-append them. + local _base_dir + local -a _base_dirs=() + IFS=':' read -ra _base_dirs <<< "${SANDBOX_PATH}" + for _base_dir in "${_base_dirs[@]}"; do + [[ -n "${_base_dir}" ]] && _path_seen["${_base_dir}"]=1 + done for _extra_dir in "${_EXTRA_PATH_DIRS[@]+"${_EXTRA_PATH_DIRS[@]}"}"; do + [[ -z "${_extra_dir}" ]] && continue + [[ -n "${_path_seen["${_extra_dir}"]:-}" ]] && continue + _path_seen["${_extra_dir}"]=1 SANDBOX_PATH="${SANDBOX_PATH}:${_extra_dir}" done } @@ -1462,6 +1722,12 @@ build_base_sandbox() { # an existing read-only bind mount. On older versions, we skip masking # entirely (the binaries remain accessible inside the sandbox). # +# Must be the very last step of every launcher, after every other mount +# (user --ro-path / --rw-path included) and after the environment builders, +# some of which add binds (bwcodex binds SSL_CERT_FILE): a bind that covers a +# masked binary or its directory (e.g. --ro-path /usr or /usr/bin) emitted +# afterwards would hide the mask. +# # Categories: # SSH – lateral movement via remote shell / file transfer # Network – arbitrary TCP/UDP connections, reverse shells @@ -1486,38 +1752,51 @@ build_binary_masks() { # Namespace / sandbox escape nsenter unshare chroot ) - # On merged-/usr systems /bin, /sbin, /usr/bin, /usr/sbin all point to the - # same directory. Multiple prefixes therefore resolve to the same realpath, - # which would emit duplicate --ro-bind /dev/null flags. Track which - # real paths have already been masked and skip repeats. - declare -A _MASKED_REAL_SEEN=() - local _bin _prefix _candidate _real + # Canonicalize + dedupe the search prefixes once. On merged-/usr systems + # /bin, /sbin, /usr/bin, /usr/sbin resolve to the same directory; collapsing + # them here avoids re-checking (and re-resolving) the same candidate under + # several aliases. Search prefixes cover RHEL, Debian, and Arch layouts. + declare -A _prefix_seen=() + local -a _canon_prefixes=() + local _p _pc + for _p in /usr/bin /usr/sbin /usr/lib/openssh /usr/libexec/openssh /bin /sbin; do + [[ -d "${_p}" ]] || continue + _pc="$(readlink -f "${_p}" 2> /dev/null || printf '%s' "${_p}")" + [[ -n "${_prefix_seen["${_pc}"]:-}" ]] && continue + _prefix_seen["${_pc}"]=1 + _canon_prefixes+=("${_pc}") + done + [[ ${#_canon_prefixes[@]} -gt 0 ]] || return 0 + + # Collect every existing candidate binary across the unique prefixes. + # -e follows symlinks: true only if the full chain resolves to a real file, + # so dangling alternatives entries are skipped (correct — nothing to mask). + local -a _candidates=() + local _bin _candidate for _bin in "${_MASKED_BINS[@]}"; do - # Search well-known prefixes covering RHEL, Debian, and Arch layouts. - for _prefix in /usr/bin /usr/sbin /usr/lib/openssh /usr/libexec/openssh \ - /bin /sbin; do - _candidate="${_prefix}/${_bin}" - # -e follows symlinks: true only if the full chain resolves to a - # real file. Broken alternatives entries (dangling symlinks) are - # skipped, which is correct — there is no binary to mask. - [[ -e "${_candidate}" ]] || continue - - # On RHEL/Debian the binary is often a symlink: - # /usr/bin/nc -> /etc/alternatives/nc -> /usr/bin/ncat - # bwrap processes --ro-bind arguments in order. At the point this - # bind is applied, /etc/alternatives has not yet been mounted in the - # sandbox, so bwrap cannot resolve the intermediate symlink and - # reports "Can't create file at /usr/bin/nc". - # - # Fix: resolve to the canonical (symlink-free) path on the host and - # use *that* as the bwrap destination. The real file is always - # directly accessible under /usr once --ro-bind /usr /usr is applied. - _real="$(realpath "${_candidate}")" - [[ -n "${_MASKED_REAL_SEEN[${_real}]:-}" ]] && continue - _MASKED_REAL_SEEN["${_real}"]=1 - BWRAP_ARGS+=(--ro-bind /dev/null "${_real}") + for _pc in "${_canon_prefixes[@]}"; do + _candidate="${_pc}/${_bin}" + [[ -e "${_candidate}" ]] && _candidates+=("${_candidate}") done done + [[ ${#_candidates[@]} -gt 0 ]] || return 0 + + # On RHEL/Debian the binary is often a symlink: + # /usr/bin/nc -> /etc/alternatives/nc -> /usr/bin/ncat + # bwrap processes --ro-bind arguments in order; at the point the bind is + # applied /etc/alternatives is not yet mounted, so bwrap cannot resolve the + # intermediate symlink ("Can't create file at /usr/bin/nc"). Resolve every + # candidate to its canonical, symlink-free path (always reachable under /usr + # once --ro-bind /usr /usr is applied) and mask *that*. A single readlink + # -f fork resolves all candidates at once (was one realpath fork each). + declare -A _masked_real_seen=() + local _real + while IFS= read -r _real; do + [[ -n "${_real}" ]] || continue + [[ -n "${_masked_real_seen["${_real}"]:-}" ]] && continue + _masked_real_seen["${_real}"]=1 + BWRAP_ARGS+=(--ro-bind /dev/null "${_real}") + done < <(readlink -f "${_candidates[@]}") } build_proc_dev_tmp() { @@ -1606,6 +1885,32 @@ build_etc_mounts() { ) } +# ── OpenCode config requirement ─────────────────────────────────── +# require_opencode_config WRAPPER USER_CONFIG_DIR +# Exit 2 unless at least a minimal OpenCode config exists: a system-wide +# /etc/opencode/opencode.json{,c} (deployed by ansible on managed hosts) or +# a user opencode.json, opencode.jsonc or config.json in USER_CONFIG_DIR. +# Either is sufficient; OpenCode layers the user config under the managed +# one. Without any, OpenCode starts with no provider or model and fails +# with a confusing error of its own. Only the files' presence is checked, +# not their content. OPENCODE_CONFIG* is not forwarded into the sandbox, +# so it is no substitute. +require_opencode_config() { + local _wrapper="$1" _user_dir="$2" _f + for _f in /etc/opencode/opencode.json /etc/opencode/opencode.jsonc; do + [[ -f "${_f}" && -r "${_f}" ]] && return 0 + done + for _f in opencode.json opencode.jsonc config.json; do + [[ -f "${_user_dir}/${_f}" && -r "${_user_dir}/${_f}" ]] && return 0 + done + printf 'Error: %s needs at least a minimal OpenCode config to run.\n' "${_wrapper}" >&2 + printf ' No config was found in either place:\n' >&2 + printf ' /etc/opencode/opencode.json or opencode.jsonc (system-wide; deployed by ansible)\n' >&2 + printf ' %s/opencode.json, opencode.jsonc or config.json (user)\n' "${_user_dir}" >&2 + printf ' Create at least one.\n' >&2 + exit 2 +} + # ── Managed system paths (read-only) ────────────────────────────── # Bind a fixed, administrator-provided path only when it exists. Resolve # aliases before binding: the source can be an /etc symlink into the managed @@ -1649,20 +1954,20 @@ build_workdir_mount() { _BIND_DIR="${_GIT_ROOT}" fi + # Canonicalize before the safety checks. pwd -P and git rev-parse already + # return physical paths, but resolving once more guarantees a symlinked + # working directory (e.g. a symlink into ~/.ssh) cannot reach + # _check_path_safe in its logical form. + local _bind_canon + _bind_canon="$(readlink -f -- "${_BIND_DIR}" 2> /dev/null || true)" + [[ -n "${_bind_canon}" ]] && _BIND_DIR="${_bind_canon}" + # Guard: reject dangerous working / git-root directories via the same # rules applied to --ro-path / --rw-path. This catches $HOME itself, # ancestors of $HOME (/home, /), /root, ~/.ssh, and all other blocked - # prefixes. _BIND_DIR comes from git rev-parse or $PWD — both are - # already canonical paths, so no readlink -f step is needed here. - _check_path_safe "${_BIND_DIR}" "working directory" - - # A directory that CONTAINS a blocked location (e.g. ~/.config with - # ~/.config/gh) is refused too: the whole tree is bound read-write. The - # check runs on the canonical path. - local _bind_canon - _bind_canon="$(readlink -f -- "${_BIND_DIR}" 2> /dev/null || true)" - [[ -n "${_bind_canon}" ]] || _bind_canon="${_BIND_DIR}" - _check_dir_contains_blocked "${_bind_canon}" "working directory" + # prefixes, and a directory that CONTAINS a blocked location (e.g. + # ~/.config with ~/.config/gh): the whole tree is bound read-write. + _check_bind_safe "${_BIND_DIR}" "working directory" # If the bind dir is under HOME, create intermediate directories in the # tmpfs so the bind mount point is reachable. _BIND_DIR is itself the @@ -1671,6 +1976,11 @@ build_workdir_mount() { BWRAP_ARGS+=( --bind "${_BIND_DIR}" "${_BIND_DIR}" ) + # Register the read-write tree so a tool resolved from inside it (e.g. + # node_modules/.bin or .pixi/envs/*/bin on PATH) does not get an --ro-bind + # on top, which would make that subtree read-only (EROFS on install). + # build_workdir_mount runs before build_dynamic_tool_mounts everywhere. + _MOUNTED_PREFIXES["${_BIND_DIR}"]=1 } # ── Git config (read-only) ────────────────────────────────────── @@ -1806,11 +2116,9 @@ _bwrap_git_recreate_link() { # with a warning; a dangling symlink is skipped silently. When the # directory is itself a symlink it is recreated as one and the files are # mounted at its real target, so ".." in includes resolves as on the host. -# Sets _BWRAP_XDG_GIT_HAS_FILES=1 when at least one file was mounted. _bwrap_mount_xdg_git_files() { local gdir="${XDG_CONFIG_HOME}/git" gdir_real dest_dir name f f_real local -a names=() reals=() - _BWRAP_XDG_GIT_HAS_FILES=0 [[ -d "${gdir}" ]] || return 0 gdir_real="$(readlink -f -- "${gdir}" 2> /dev/null || true)" [[ -n "${gdir_real}" ]] || return 0 @@ -1845,7 +2153,6 @@ _bwrap_mount_xdg_git_files() { for _k in "${!names[@]}"; do BWRAP_ARGS+=(--ro-bind "${reals[${_k}]}" "${dest_dir}/${names[${_k}]}") done - _BWRAP_XDG_GIT_HAS_FILES=1 } # _bwrap_git_lexical_mount RAW INC_PATH @@ -2054,6 +2361,181 @@ build_user_bin_mount() { fi } +# ── Authoring directories (read-write, persistent) ────────────── +# The tools let users author reusable definitions (skills, agents, commands, +# prompts) as plain markdown under their config/home directory, and the agent +# is expected to be able to create and edit them from inside the sandbox. The +# tool's home/config directory is a tmpfs, so each such directory must be +# bind-mounted read-write from its real host location to survive the session. +# +# _path_visible_from_host PATH +# Return 0 when PATH already shows its host contents in the sandbox given +# the mounts emitted so far, i.e. the LAST mount in BWRAP_ARGS covering PATH +# (at PATH or an ancestor) is an identity bind (source == destination). A +# registered ancestor in _MOUNTED_PREFIXES is not enough: a later tmpfs (a +# tool home such as CODEX_HOME=$PWD/.codex inside the working directory) or +# a bind from a different source hides what an earlier bind exposed. +_path_visible_from_host() { + local _path="$1" _i=0 _flag _src _dest _visible=0 + while [[ ${_i} -lt ${#BWRAP_ARGS[@]} ]]; do + _flag="${BWRAP_ARGS[${_i}]}" + _bwrap_flag_arity "${_flag}" + case "${_flag}" in + --tmpfs) + _dest="${BWRAP_ARGS[$((_i + 1))]}" + _is_prefix_of "${_dest}" "${_path}" && _visible=0 + ;; + --bind | --ro-bind | --bind-try | --ro-bind-try | --dev-bind | --dev-bind-try) + _src="${BWRAP_ARGS[$((_i + 1))]}" + _dest="${BWRAP_ARGS[$((_i + 2))]}" + if _is_prefix_of "${_dest}" "${_path}"; then + if [[ "${_src}" == "${_dest}" ]]; then + _visible=1 + else + _visible=0 + fi + fi + ;; + esac + _i=$((_i + 1 + _BWRAP_FLAG_ARITY)) + done + [[ ${_visible} -eq 1 ]] +} + +# _mount_authoring_dir DIR CREATE KIND [SRC] +# Mount DIR read-write. CREATE=1 creates it on the host first (skipped +# under --dry-run, which must not mutate host state) so a first-time user +# can author into it; CREATE=0 mounts it only when it already exists. KIND +# is the word used in warnings (e.g. "skill"). +# +# SRC is the host directory to mount at DIR, for when the tool looks in one +# place and the files live in another (default: DIR itself). CREATE and the +# existence test apply to SRC. +# +# The directory is resolved with readlink -f and bound whole at its logical +# path, so entries created inside the sandbox persist on the host. An +# entry that is a symlink (a common layout symlinks each skill in from a +# separate repository) would dangle, since that repository is not +# otherwise mounted, so each symlink's resolved target is also bound +# read-write at its own path. Only that target is exposed, not the +# repository around it. A target that resolves into a blocked location, or +# is a directory containing one, is skipped with a warning. With a +# distinct SRC a RELATIVE symlink would resolve against DIR inside the +# sandbox rather than against SRC, so it is skipped with a warning. +# +# bwrap cannot bind over a symlink inside a bound directory, so when DIR +# itself is reached through a symlink (its resolved path differs from DIR) +# the symlink targets are not bound this way. That case falls back to +# binding each entry individually, at its logical path, over a tmpfs +# directory: existing entries work, but one created inside the sandbox is +# discarded on exit. +# +# A DIR that resolves into a blocked location, contains one, or is already +# covered by an earlier bind (e.g. inside the working directory) is left +# alone. Must be called AFTER the tool's own home mount: that mount may be +# a tmpfs that would hide a directory bound underneath it. +_mount_authoring_dir() { + local _dir="$1" _create="$2" _kind="$3" _src="${4:-$1}" + local _canon _entry _real _link + + if [[ ! -e "${_src}" ]]; then + [[ "${_create}" -eq 1 ]] || return 0 + elif [[ ! -d "${_src}" ]]; then + return 0 + fi + + # -m, not -f: a directory about to be created may have missing parents + # too (a fresh ~/.config/opencode), and -f yields nothing for those. + _canon="$(readlink -m -- "${_src}" 2> /dev/null || true)" + [[ -n "${_canon}" ]] || return 0 + if _bwrap_bind_blocked "${_canon}"; then + echo "Warning: ${_kind} directory '${_src}' resolves into or contains a blocked location (${_canon}); not mounted." >&2 + return 0 + fi + if [[ "${_src}" == "${_dir}" ]]; then + _path_visible_from_host "${_dir}" && return 0 + fi + + if [[ "${_create}" -eq 1 && "${DRY_RUN}" -eq 0 ]]; then + mkdir -p -- "${_canon}" + fi + + _emit_home_intermediate_dirs "${_dir}" inclusive + + if [[ "${_src}" == "${_dir}" && "${_canon}" != "${_dir}" ]]; then + [[ -d "${_canon}" ]] || return 0 + for _entry in "${_dir}"/* "${_dir}"/.[!.]* "${_dir}"/..?*; do + [[ -e "${_entry}" ]] || continue + _real="$(readlink -f -- "${_entry}" 2> /dev/null || true)" + [[ -n "${_real}" ]] || continue + if _bwrap_bind_blocked "${_real}"; then + echo "Warning: ${_kind} '${_entry}' resolves into or contains a blocked location (${_real}); not mounted." >&2 + continue + fi + BWRAP_ARGS+=(--bind "${_real}" "${_entry}") + done + _MOUNTED_PREFIXES["${_dir}"]=1 + return 0 + fi + + BWRAP_ARGS+=(--bind "${_canon}" "${_dir}") + _MOUNTED_PREFIXES["${_dir}"]=1 + + [[ -d "${_canon}" ]] || return 0 + for _entry in "${_canon}"/* "${_canon}"/.[!.]* "${_canon}"/..?*; do + [[ -L "${_entry}" ]] || continue + _real="$(readlink -f -- "${_entry}" 2> /dev/null || true)" + [[ -n "${_real}" && -e "${_real}" ]] || continue + if [[ "${_src}" != "${_dir}" ]]; then + _link="$(readlink -- "${_entry}" 2> /dev/null || true)" + if [[ "${_link}" != /* ]]; then + echo "Warning: ${_kind} '${_entry}' is a relative symlink and would not resolve at '${_dir}'; not mounted." >&2 + continue + fi + fi + _path_visible_from_host "${_real}" && continue + if _bwrap_bind_blocked "${_real}"; then + echo "Warning: ${_kind} '${_entry}' resolves into or contains a blocked location (${_real}); not mounted." >&2 + continue + fi + _emit_home_intermediate_dirs "${_real}" + BWRAP_ARGS+=(--bind "${_real}" "${_real}") + _MOUNTED_PREFIXES["${_real}"]=1 + done +} + +# build_authoring_dir_mounts KIND DIR... +# Mount each DIR read-write, creating it on the host when missing so +# authoring works out of the box. Use for the tool's own (primary) +# location of each kind of definition. KIND is "skill", "agent", +# "command" or "prompt". +build_authoring_dir_mounts() { + local _kind="$1" _dir + shift + for _dir in "$@"; do + _mount_authoring_dir "${_dir}" 1 "${_kind}" + done +} + +# build_authoring_dir_mounts_from KIND SRC DEST +# Like build_authoring_dir_mounts for one directory, but mount the host +# directory SRC (created when missing) at DEST, where the tool looks. +build_authoring_dir_mounts_from() { + _mount_authoring_dir "$3" 1 "$1" "$2" +} + +# build_personal_authoring_mounts KIND DIR... +# Mount additional locations the tool also reads (e.g. ~/.claude/skills or +# ~/.agents/skills) read-write. Missing directories are skipped, not +# created. +build_personal_authoring_mounts() { + local _kind="$1" _dir + shift + for _dir in "$@"; do + _mount_authoring_dir "${_dir}" 0 "${_kind}" + done +} + # ── NSLS-II software (read-only) ──────────────────────────────── # Only bind if the directory exists on the host system. build_nsls2_mount() { @@ -2075,6 +2557,7 @@ build_dynamic_tool_mounts() { /usr /bin /sbin /lib /lib64 \ /proc /dev /tmp \ "${PIXI_HOME_DIR}" \ + "${_HOME}/bin" \ /nsls2/software/bin; do [[ -e "${_preloaded}" ]] && _MOUNTED_PREFIXES["${_preloaded}"]=1 done @@ -2097,38 +2580,17 @@ build_dynamic_tool_mounts() { } # ── Environment variables ──────────────────────────────────────── -# Start from a clean environment and only pass through what we need. +# The sandbox environment is exactly bwrap's environment, which +# launch_sandbox builds from scratch with `env -i` (a few base variables plus +# SANDBOX_ENV), so no host variable reaches it unless passed through here. +# bwrap runs WITHOUT --clearenv so that environment is handed to the sandbox; +# the --setenv entries below are applied on top of it and take precedence. build_env_vars() { - if [[ "${HAS_CLEARENV}" -eq 1 ]]; then - BWRAP_ARGS+=(--clearenv) - else - # Fallback for bwrap < 0.5.0: manually unset all host env vars. - # We use compgen -e to list all exported vars and unset each one. - # Note: we already exec with env -i, but --unsetenv ensures any - # vars inherited through other means are also cleared. - # - # Read line-by-line (not `for x in $(...)`) so names are not - # word-split on IFS, and skip anything that is not a portable - # shell identifier — bash exports function definitions under names - # like `BASH_FUNC_module%%`, which would become invalid --unsetenv - # arguments. - # - # On HPC login nodes the exported-var count can reach the hundreds; - # each becomes a separate --unsetenv pair, so this path can produce - # a long bwrap command line. That is unavoidable here and harmless - # (well under ARG_MAX); the modern --clearenv path above avoids it. - local _envvar - while IFS= read -r _envvar; do - [[ "${_envvar}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue - BWRAP_ARGS+=(--unsetenv "${_envvar}") - done < <(compgen -e) - fi - # Always set BWRAP_ARGS+=( --setenv HOME "${_HOME}" - --setenv USER "${USER}" - --setenv LOGNAME "${USER}" + --setenv USER "${_USER}" + --setenv LOGNAME "${_USER}" --setenv SHELL "${SANDBOX_SHELL}" --setenv PATH "${SANDBOX_PATH}" --setenv PWD "${_PWD}" @@ -2215,28 +2677,31 @@ resolve_env_bin() { } # _bwrap_flag_arity FLAG -# Echo the number of value arguments that bwrap flag FLAG consumes (0, 1, -# or 2). Used by print_dry_run to group flags with their values WITHOUT +# Set _BWRAP_FLAG_ARITY to the number of value arguments that bwrap flag +# FLAG consumes (0, 1, or 2). Used to walk BWRAP_ARGS (print_dry_run, +# build_extra_path_mounts) grouping flags with their values WITHOUT # guessing from the value's leading characters — a path value that happens # to start with "--" (e.g. a user-supplied --ro-path) must not be mistaken # for a new flag. Only the flags this library actually emits are listed; -# the default of 1 is a safe fallback for any single-value flag. +# the default of 1 is a safe fallback for any single-value flag. Sets a +# global rather than echoing so the walks do not fork once per argument. +_BWRAP_FLAG_ARITY=1 _bwrap_flag_arity() { case "$1" in --unshare-* | --share-net | --die-with-parent | --new-session | \ --clearenv | --as-pid-1) - echo 0 + _BWRAP_FLAG_ARITY=0 ;; --ro-bind | --ro-bind-try | --bind | --bind-try | --dev-bind | \ --dev-bind-try | --symlink | --setenv | \ --file | --bind-data | --ro-bind-data) - echo 2 + _BWRAP_FLAG_ARITY=2 ;; --dir | --tmpfs | --proc | --dev | --unsetenv | --chdir | --hostname) - echo 1 + _BWRAP_FLAG_ARITY=1 ;; *) - echo 1 + _BWRAP_FLAG_ARITY=1 ;; esac } @@ -2259,24 +2724,36 @@ print_dry_run() { _ENV_BIN="$(resolve_env_bin)" # Mirror the exact invocation used by launch_sandbox: - # exec -i -- <_TOOL_CMD> + # exec -i -- <_TOOL_CMD> + # Values of secret-looking variables (_is_secret_env_name) are printed as + # REDACTED, on the env line and in --setenv; only the printed text changes. printf "exec %s -i \\\\\n" "${_ENV_BIN}" printf " HOME=%q \\\\\n" "${_HOME}" - printf " USER=%q \\\\\n" "${USER}" - printf " LOGNAME=%q \\\\\n" "${USER}" + printf " USER=%q \\\\\n" "${_USER}" + printf " LOGNAME=%q \\\\\n" "${_USER}" printf " PATH=%q \\\\\n" "/usr/bin:/bin" - printf " LANG=%q \\\\\n" "${LANG:-C.UTF-8}" - printf " LC_CTYPE=%q \\\\\n" "${LC_CTYPE:-C.UTF-8}" + local _entry _name + for _entry in ${SANDBOX_ENV[@]+"${SANDBOX_ENV[@]}"}; do + _name="${_entry%%=*}" + if _is_secret_env_name "${_name}"; then + printf " %s='REDACTED' \\\\\n" "${_name}" + else + printf " %s=%q \\\\\n" "${_name}" "${_entry#*=}" + fi + done printf " %s \\\\\n" "${_BWRAP_BIN}" # Print bwrap arguments one flag-plus-values per line. Each flag's # value count comes from _bwrap_flag_arity, so a value beginning with # "--" is grouped with its flag instead of being misread as a new flag. + # Values are %q-quoted so a path containing a quote, space or other + # special character still yields a valid, pasteable command. local i=0 arg _arity _j while [[ $i -lt ${#BWRAP_ARGS[@]} ]]; do arg="${BWRAP_ARGS[$i]}" - _arity="$(_bwrap_flag_arity "${arg}")" - printf " %s" "${arg}" + _bwrap_flag_arity "${arg}" + _arity="${_BWRAP_FLAG_ARITY}" + printf " %q" "${arg}" i=$((i + 1)) for ((_j = 0; _j < _arity && i < ${#BWRAP_ARGS[@]}; _j++)); do # Redact the VALUE of secret-looking --setenv variables (only the @@ -2285,7 +2762,7 @@ print_dry_run() { _is_secret_env_name "${BWRAP_ARGS[$((i - 1))]}"; then printf " 'REDACTED'" else - printf " '%s'" "${BWRAP_ARGS[$i]}" + printf " %q" "${BWRAP_ARGS[$i]}" fi i=$((i + 1)) done @@ -2303,6 +2780,12 @@ print_dry_run() { # Launch bwrap itself with a scrubbed environment so /proc/1/environ # does not leak host variables (SSH_AUTH_SOCK, DBUS_SESSION_BUS_ADDRESS, etc.). # +# Forwarded values (SANDBOX_ENV, which includes API keys and tokens) travel in +# bwrap's environment, not on its command line: /proc//cmdline of the +# long-running bwrap process is readable by every local user (`ps`), while +# /proc//environ is readable only by its owner. They are on `env`'s own +# command line only for the instant before it execs bwrap. +# # `env` is resolved via resolve_env_bin (prefers /usr/bin/env) rather than # via PATH lookup at exec time — this prevents a user shell script named # "env" earlier on PATH from hijacking the launch. See resolve_env_bin. @@ -2313,10 +2796,9 @@ launch_sandbox() { exec "${_ENV_BIN}" -i \ HOME="${_HOME}" \ - USER="${USER}" \ - LOGNAME="${USER}" \ + USER="${_USER}" \ + LOGNAME="${_USER}" \ PATH="/usr/bin:/bin" \ - LANG="${LANG:-C.UTF-8}" \ - LC_CTYPE="${LC_CTYPE:-C.UTF-8}" \ + ${SANDBOX_ENV[@]+"${SANDBOX_ENV[@]}"} \ "${_BWRAP_BIN}" "${BWRAP_ARGS[@]}" -- "${_TOOL_CMD[@]}" ${TOOL_ARGS[@]+"${TOOL_ARGS[@]}"} } diff --git a/lib/gpg-passwd.sh b/lib/gpg-passwd.sh index 1c2e796..c07c760 100644 --- a/lib/gpg-passwd.sh +++ b/lib/gpg-passwd.sh @@ -13,6 +13,8 @@ # 2 - decrypt succeeded but a required variable is still unset # or empty (also returned when gpg_file argument is missing) # 3 - gpg decrypt failed +# 4 - eval of decrypted content failed (syntax error or failing +# command) # # Usage (in a caller script): # # Source the library by its installed path; substitute the correct @@ -21,46 +23,90 @@ # source /n2snscripts/lib/gpg-passwd.sh # decrypt_env_file "$HOME/.zshrc.private.gpg" AIFAPIM_HOST AIFAPIM_API_KEY # +# Calling convention under `set -e`: +# decrypt_env_file runs `gpg` in a command substitution. Under a caller's +# `set -e`, a non-zero exit from that command substitution can abort the +# caller *before* it observes the function's return code, so the documented +# return 3 (and return 2/0) are only reliably visible when the call is +# guarded. Always capture the status explicitly, e.g.: +# rc=0 +# decrypt_env_file "$HOME/.private.gpg" AIFAPIM_HOST || rc=$? +# case "$rc" in ... esac +# The trailing `|| rc=$?` both captures the code and suppresses `set -e`. +# # Portability: -# This file uses only portable bash syntax and works in both bash and -# zsh; the original lives in dotfiles/config/bashrc-functions and is -# also sourced from zshrc-functions. +# Works in both bash and zsh. Required-variable values are read by +# indirection through `eval "value=\${$name:-}"`; the name is first +# validated to be a plain shell identifier (and rejected if it starts with +# the reserved __gpgpw_ prefix), so the eval can neither inject code nor +# read one of this function's own locals. Bash `local -n` namerefs are not +# available in zsh, so eval indirection is the portable alternative. # Guard against double-sourcing. [[ -n "${_GPG_PASSWD_LIB_SOURCED:-}" ]] && return 0 _GPG_PASSWD_LIB_SOURCED=1 +# All function-local variables are prefixed `__gpgpw_` so the eval indirection +# below cannot read one of this function's own locals when a caller passes a +# required-var name that collides with a local. Required-var names that +# themselves start with `__gpgpw_`, or that are not plain shell identifiers, +# are rejected (return 2) to close the residual collision and injection risks. decrypt_env_file() { - local gpg_file="$1" + local __gpgpw_file="${1:-}" shift || true - local required_vars=("$@") + local __gpgpw_required=("$@") - if [[ -z "$gpg_file" ]]; then + if [[ -z "$__gpgpw_file" ]]; then printf 'decrypt_env_file: missing gpg_file argument\n' >&2 return 2 fi - if [[ ! -r "$gpg_file" ]]; then - printf 'decrypt_env_file: %s not found or unreadable; skipping decrypt.\n' "$gpg_file" >&2 + if [[ ! -r "$__gpgpw_file" ]]; then + printf 'decrypt_env_file: %s not found or unreadable; skipping decrypt.\n' "$__gpgpw_file" >&2 return 0 fi - local decrypted gpg_rc - decrypted="$(gpg --quiet --batch --yes --decrypt "$gpg_file")" - gpg_rc=$? - if ((gpg_rc != 0)); then - printf 'decrypt_env_file: gpg --decrypt %s failed (exit %d).\n' "$gpg_file" "$gpg_rc" >&2 + local __gpgpw_decrypted __gpgpw_rc + __gpgpw_decrypted="$(gpg --quiet --batch --yes --decrypt "$__gpgpw_file")" + __gpgpw_rc=$? + if ((__gpgpw_rc != 0)); then + printf 'decrypt_env_file: gpg --decrypt %s failed (exit %d).\n' "$__gpgpw_file" "$__gpgpw_rc" >&2 return 3 fi - eval "$decrypted" - unset decrypted + # Capture the status directly: inside `if ! eval ...`, $? would be the + # negated pipeline's status (always 0), not eval's. + eval "$__gpgpw_decrypted" + __gpgpw_rc=$? + unset __gpgpw_decrypted + if ((__gpgpw_rc != 0)); then + printf 'decrypt_env_file: eval of decrypted content from %s failed (exit %d).\n' "$__gpgpw_file" "$__gpgpw_rc" >&2 + return 4 + fi - local var value - for var in "${required_vars[@]}"; do - local -n _ref="$var" - value="${_ref:-}" - if [[ -z "$value" ]]; then - printf 'decrypt_env_file: required variable %s is still empty after decrypt.\n' "$var" >&2 + local __gpgpw_var __gpgpw_value + # Guard the expansion so an empty required-vars array does not trip + # `set -u` on bash < 4.4. + for __gpgpw_var in ${__gpgpw_required[@]+"${__gpgpw_required[@]}"}; do + # Validate the name before the eval indirection below. Reject the + # reserved __gpgpw_ prefix (would read one of this function's locals) + # and anything that is not a plain shell identifier (would let the + # eval inject code). Pure glob patterns so the check behaves + # identically in bash and zsh. + case "$__gpgpw_var" in + __gpgpw_*) + printf 'decrypt_env_file: required variable name %s is reserved (must not start with __gpgpw_).\n' "$__gpgpw_var" >&2 + return 2 + ;; + '' | [!A-Za-z_]* | *[!A-Za-z0-9_]*) + printf 'decrypt_env_file: required variable name %s is not a valid shell identifier.\n' "$__gpgpw_var" >&2 + return 2 + ;; + esac + # eval indirection for portability: `local -n` namerefs are bash-only. + # Safe: the name is a validated identifier, so only the caller's variable is read. + eval "__gpgpw_value=\${$__gpgpw_var:-}" + if [[ -z "$__gpgpw_value" ]]; then + printf 'decrypt_env_file: required variable %s is still empty after decrypt.\n' "$__gpgpw_var" >&2 return 2 fi done diff --git a/pixi.lock b/pixi.lock index 548624b..74fb48b 100644 --- a/pixi.lock +++ b/pixi.lock @@ -108,7 +108,6 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/ast-serialize-0.5.0-py310hd8a072f_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.0.0-py313hf46b229_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h33c6efd_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_102.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.1-hecca717_1.conda @@ -124,71 +123,43 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/mypy-2.1.0-py313hc25a8b5_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/prek-0.5.6-hf19af3b_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/psutil-7.2.2-py313h54dd161_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.13.14-h6add32d_100_cp313.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/python-librt-0.11.0-py313h54dd161_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/ruff-0.15.8-h7805a7d_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/ukkonen-1.1.0-py313h7037e92_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/yaml-0.2.5-h280c20c_3.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zizmor-1.23.1-hb17b654_0.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/codespell-2.4.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.13.14-py313hd8ed1ab_100.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.3-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/filelock-3.29.4-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/identify-2.6.19-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-9.0.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/mypy_extensions-1.1.0-pyha770c72_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pathspec-1.1.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.6.0-pyha770c72_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-3.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-discovery-1.4.2-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.13.14-h4df99d1_100.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pyyaml-6.0.3-pyh7db6752_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-21.5.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/yamllint-1.38.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda osx-64: - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/codespell-2.4.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.13.14-py313hd8ed1ab_100.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.3-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/filelock-3.29.4-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/identify-2.6.19-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-9.0.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/mypy_extensions-1.1.0-pyha770c72_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pathspec-1.1.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.6.0-pyha770c72_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-3.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-discovery-1.4.2-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.13.14-h4df99d1_100.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pyyaml-6.0.3-pyh7db6752_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-21.5.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/yamllint-1.38.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/ast-serialize-0.5.0-py310hb9b2626_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/bzip2-1.0.8-h500dc9f_9.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/cffi-2.0.0-py313hf57695f_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/libcxx-22.1.8-h19cb2f5_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libexpat-2.8.1-hcc62823_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/libffi-3.5.2-hd1f9c09_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/liblzma-5.8.3-hbb4bfdb_0.conda @@ -198,46 +169,31 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/mypy-2.1.0-py313h0b0ee5e_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/ncurses-6.6-hcc0dc9a_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/openssl-3.6.3-hc881268_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/prek-0.5.6-he704061_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/psutil-7.2.2-py313h16366db_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/python-3.13.14-h3d5d122_100_cp313.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/python-librt-0.11.0-py313h22ab4a2_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/readline-8.3-h68b038d_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/ruff-0.15.8-h16586dd_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-h7142dee_3.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/ukkonen-1.1.0-py313h252b9d7_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/yaml-0.2.5-h4132b18_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/zizmor-1.23.1-h19f9e61_0.conda osx-arm64: - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/codespell-2.4.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.13.14-py313hd8ed1ab_100.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.3-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/filelock-3.29.4-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/identify-2.6.19-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-9.0.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/mypy_extensions-1.1.0-pyha770c72_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pathspec-1.1.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.6.0-pyha770c72_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-3.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-discovery-1.4.2-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.13.14-h4df99d1_100.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pyyaml-6.0.3-pyh7db6752_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-21.5.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/yamllint-1.38.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ast-serialize-0.5.0-py310h3b8a9b8_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_9.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cffi-2.0.0-py313h224173a_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-78.3-hef89b57_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-22.1.8-h55c6f16_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.1-hf6b4638_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-hcf2aa1b_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_0.conda @@ -247,43 +203,29 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/mypy-2.1.0-py313h3e91af1_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-h1d4f5a5_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/prek-0.5.6-hc2d82ec_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/psutil-7.2.2-py313h6688731_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.13.14-h448ec07_100_cp313.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-librt-0.11.0-py313h6688731_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ruff-0.15.8-hc5c3a1d_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h010d191_3.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ukkonen-1.1.0-py313h5c29297_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/yaml-0.2.5-h925e9cb_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zizmor-1.23.1-h6fdd925_0.conda win-64: - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-h4c7d964_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/codespell-2.4.2-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.13.14-py313hd8ed1ab_100.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.3-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/filelock-3.29.4-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/identify-2.6.19-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-9.0.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/mypy_extensions-1.1.0-pyha770c72_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pathspec-1.1.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.6.0-pyha770c72_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-3.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-discovery-1.4.2-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.13.14-h4df99d1_100.conda - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-21.5.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/yamllint-1.38.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/ast-serialize-0.5.0-py310ha413424_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/bzip2-1.0.8-h0ad9c76_9.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/cffi-2.0.0-py313h5ea7bf4_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.8.1-hac47afa_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/libffi-3.5.2-h3d046cb_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.3-hfd05255_0.conda @@ -292,6 +234,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.2-hfd05255_2.conda - conda: https://conda.anaconda.org/conda-forge/win-64/mypy-2.1.0-py313h4ac8b90_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.3-hf411b9b_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/prek-0.5.6-h18a1a76_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/psutil-7.2.2-py313h5fd188c_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/python-3.13.14-h09917c8_100_cp313.conda - conda: https://conda.anaconda.org/conda-forge/win-64/python-librt-0.11.0-py313h5fd188c_0.conda @@ -299,7 +242,6 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/ruff-0.15.8-h02f8532_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/tk-8.6.13-h6ed50ae_3.conda - conda: https://conda.anaconda.org/conda-forge/win-64/ucrt-10.0.26100.0-h57928b3_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/ukkonen-1.1.0-py313hf069bd2_0.conda - conda: https://conda.anaconda.org/conda-forge/win-64/vc-14.5-h1b7c187_39.conda - conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.51.36231-h1b9f54f_39.conda - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36231-h1b9f54f_39.conda @@ -345,20 +287,6 @@ packages: license_family: BSD size: 260182 timestamp: 1771350215188 -- conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.0.0-py313hf46b229_1.conda - sha256: 2162a91819945c826c6ef5efe379e88b1df0fe9a387eeba23ddcf7ebeacd5bd6 - md5: d0616e7935acab407d1543b28c446f6f - depends: - - __glibc >=2.17,<3.0.a0 - - libffi >=3.5.2,<3.6.0a0 - - libgcc >=14 - - pycparser - - python >=3.13,<3.14.0a0 - - python_abi 3.13.* *_cp313 - license: MIT - license_family: MIT - size: 298357 - timestamp: 1761202966461 - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h33c6efd_0.conda sha256: fbf86c4a59c2ed05bbffb2ba25c7ed94f6185ec30ecb691615d42342baa1a16a md5: c80d8a3b84358cb967fa81e7075fbc8a @@ -529,6 +457,19 @@ packages: license_family: Apache size: 3159683 timestamp: 1781069855778 +- conda: https://conda.anaconda.org/conda-forge/linux-64/prek-0.5.6-hf19af3b_0.conda + sha256: 66d983181dd8dfc9729d629f5288c53ba4086fadc7b2fe3f8008e2a9399ce2ce + md5: 4fed9cd7d24477b074c35c9c5e4dbbc9 + depends: + - libgcc >=15 + - __glibc >=2.17,<3.0.a0 + constrains: + - __glibc >=2.17 + license: MIT + license_family: MIT + run_exports: {} + size: 5051533 + timestamp: 1792492186945 - conda: https://conda.anaconda.org/conda-forge/linux-64/psutil-7.2.2-py313h54dd161_0.conda sha256: f19fd682d874689dfde20bf46d7ec1a28084af34583e0405685981363af47c91 md5: 25fe6e02c2083497b3239e21b49d8093 @@ -617,20 +558,6 @@ packages: license_family: BSD size: 3301196 timestamp: 1769460227866 -- conda: https://conda.anaconda.org/conda-forge/linux-64/ukkonen-1.1.0-py313h7037e92_0.conda - sha256: 7f2e4f38e57c17858c644259a1be868d6e98780239fd93bfa057cb5cfc24a928 - md5: cb423e0853b3dde2b3738db4dedf5ba2 - depends: - - __glibc >=2.17,<3.0.a0 - - cffi - - libgcc >=14 - - libstdcxx >=14 - - python >=3.13,<3.14.0a0 - - python_abi 3.13.* *_cp313 - license: MIT - license_family: MIT - size: 14910 - timestamp: 1769438729201 - conda: https://conda.anaconda.org/conda-forge/linux-64/yaml-0.2.5-h280c20c_3.conda sha256: 6d9ea2f731e284e9316d95fa61869fe7bbba33df7929f82693c121022810f4ad md5: a77f85f77be52ff59391544bfe73390a @@ -689,15 +616,6 @@ packages: license: ISC size: 128866 timestamp: 1781708962055 -- conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda - sha256: aa589352e61bb221351a79e5946d56916e3c595783994884accdb3b97fe9d449 - md5: 381bd45fb7aa032691f3063aff47e3a1 - depends: - - python >=3.10 - license: MIT - license_family: MIT - size: 13589 - timestamp: 1763607964133 - conda: https://conda.anaconda.org/conda-forge/noarch/codespell-2.4.2-pyhd8ed1ab_0.conda sha256: d09a16087e14faaa92af87a8dcb30e7f85eed2ad1e1f994bb228a482d104d94c md5: dc82cad3ba612ecccbabeb988ca49eae @@ -717,45 +635,6 @@ packages: license: Python-2.0 size: 48337 timestamp: 1781257766256 -- conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.3-pyhcf101f3_0.conda - sha256: e2753997b8bd34205f42be01b8bab8037423dc30c02a1ec12de23e5b4c0b0a2e - md5: 58638f77697c4f6726753eb8be34818b - depends: - - python >=3.10 - - python - license: Apache-2.0 - license_family: APACHE - size: 303705 - timestamp: 1781320269259 -- conda: https://conda.anaconda.org/conda-forge/noarch/filelock-3.29.4-pyhd8ed1ab_0.conda - sha256: feb5c13cc8f256212a979783a7645abd7e27925c51ee5431babbc0efc661cdfd - md5: 66f138d7a6dffb5c959cc4bf6dc2b797 - depends: - - python >=3.10 - license: Unlicense - size: 36989 - timestamp: 1781381078337 -- conda: https://conda.anaconda.org/conda-forge/noarch/identify-2.6.19-pyhd8ed1ab_0.conda - sha256: 381cedccf0866babfc135d65ee40b778bd20e927d2a5ec810f750c5860a7c5b8 - md5: 84a3233b709a289a4ddd7a2fd27dd988 - depends: - - python >=3.10 - - ukkonen - license: MIT - license_family: MIT - size: 79757 - timestamp: 1776455344188 -- conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-9.0.0-pyhcf101f3_0.conda - sha256: 43e2a5497cad1598ff88a3e69f69bc88b7b8f141fa63c60eab5db296317318b8 - md5: ffc17e785d64e12fc311af9184221839 - depends: - - python >=3.10 - - zipp >=3.20 - - python - license: Apache-2.0 - license_family: APACHE - size: 34766 - timestamp: 1779714582554 - conda: https://conda.anaconda.org/conda-forge/noarch/mypy_extensions-1.1.0-pyha770c72_0.conda sha256: 6ed158e4e5dd8f6a10ad9e525631e35cee8557718f83de7a4e3966b1f772c4b1 md5: e9c622e0d00fa24a6292279af3ab6d06 @@ -765,16 +644,6 @@ packages: license_family: MIT size: 11766 timestamp: 1745776666688 -- conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda - sha256: 4fa40e3e13fc6ea0a93f67dfc76c96190afd7ea4ffc1bac2612d954b42cdc3ee - md5: eb52d14a901e23c39e9e7b4a1a5c015f - depends: - - python >=3.10 - - setuptools - license: BSD-3-Clause - license_family: BSD - size: 40866 - timestamp: 1766261270149 - conda: https://conda.anaconda.org/conda-forge/noarch/pathspec-1.1.1-pyhd8ed1ab_0.conda sha256: 6eaee417d33f298db79bc7185ab1208604c0e6cf51dade34cd513c6f9db9c6f3 md5: 11adc78451c998c0fd162584abfa3559 @@ -784,52 +653,6 @@ packages: license_family: MOZILLA size: 56559 timestamp: 1777271601895 -- conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda - sha256: 9e5e1fd3506ccfc4d444fc4d2d39b0ed097d5d0e3bd3d4bdf6bcc81aaf66860d - md5: 2c5ef45db85d34799771629bd5860fd7 - depends: - - python >=3.10 - - python - license: MIT - license_family: MIT - size: 26308 - timestamp: 1779972894916 -- conda: https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.6.0-pyha770c72_0.conda - sha256: 716960bf0a9eb334458a26b3bdcb17b8d0786062138a4f48c7f335c8418c5d0b - md5: 7859736b4f8ebe6c8481bf48d91c9a1e - depends: - - cfgv >=2.0.0 - - identify >=1.0.0 - - nodeenv >=0.11.1 - - python >=3.10 - - pyyaml >=5.1 - - virtualenv >=20.10.0 - license: MIT - license_family: MIT - size: 201606 - timestamp: 1776858157327 -- conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-3.0-pyhcf101f3_0.conda - sha256: e27e0473fc6723311a0bd48b89b616fa1b996a2f7a2b555338cbbcfb9c640568 - md5: 9c5491066224083c41b6d5635ed7107b - depends: - - python >=3.10 - - python - license: BSD-3-Clause - license_family: BSD - size: 55886 - timestamp: 1779293633166 -- conda: https://conda.anaconda.org/conda-forge/noarch/python-discovery-1.4.2-pyhcf101f3_0.conda - sha256: 6914da740f6e3ec44ffb2f687dbc9c33abf084e42f34e3a8bb8235e475850619 - md5: 7a9095c9300d1b50b1785ca9bc4cadae - depends: - - python >=3.10 - - filelock >=3.15.4 - - platformdirs <5,>=4.3.6 - - python - license: MIT - license_family: MIT - size: 35514 - timestamp: 1781257630962 - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.13.14-h4df99d1_100.conda sha256: c7a8f98ea1cda5a84377c236ccd4bf1b6e2212c5a258d60bba295fb9f0260235 md5: 200323d73f85b9c5c411db8c8c4942db @@ -861,15 +684,6 @@ packages: license_family: MIT size: 45525 timestamp: 1770223374054 -- conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda - sha256: 82088a6e4daa33329a30bc26dc19a98c7c1d3f05c0f73ce9845d4eab4924e9e1 - md5: 8e194e7b992f99a5015edbd4ebd38efd - depends: - - python >=3.10 - license: MIT - license_family: MIT - size: 639697 - timestamp: 1773074868565 - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda sha256: 032271135bca55aeb156cee361c81350c6f3fb203f57d024d7e5a1fc9ef18731 md5: 0caa1af407ecff61170c9437a808404d @@ -886,22 +700,6 @@ packages: license: LicenseRef-Public-Domain size: 119135 timestamp: 1767016325805 -- conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-21.5.1-pyhcf101f3_0.conda - sha256: 0a7b0a2ada7ad719f9d4f8874eb10911e1fcfdecefc86456105eb806ebd60ac4 - md5: e449fb99b714be1e13fa5564dacd1af5 - depends: - - python >=3.10 - - distlib >=0.3.7,<1 - - filelock <4,>=3.24.2 - - importlib-metadata >=6.6 - - platformdirs >=3.9.1,<5 - - python-discovery >=1.4.2 - - typing_extensions >=4.13.2 - - python - license: MIT - license_family: MIT - size: 3111990 - timestamp: 1781651033074 - conda: https://conda.anaconda.org/conda-forge/noarch/yamllint-1.38.0-pyhcf101f3_1.conda sha256: 7f5bcc0f059c607ccd65fa1b82d8b369c2598a6e86c31f7a4995bc2f2753e2eb md5: db30cb6c0910cc35e35d3955cf373df2 @@ -914,16 +712,6 @@ packages: license_family: GPL size: 111901 timestamp: 1770937575481 -- conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - sha256: 210bd31c22bb88f5e2a167df24c95bb5f152b2ada7502f9b8c49d1f5366db423 - md5: ba3dcdc8584155c97c648ae9c044b7a3 - depends: - - python >=3.10 - - python - license: MIT - license_family: MIT - size: 24190 - timestamp: 1779159948016 - conda: https://conda.anaconda.org/conda-forge/osx-64/ast-serialize-0.5.0-py310hb9b2626_1.conda noarch: python sha256: 9d38da0aa9f1034eecf97151a26686f8a6261cbfe27eb486e80e0d86e0169d33 @@ -948,28 +736,6 @@ packages: license_family: BSD size: 133427 timestamp: 1771350680709 -- conda: https://conda.anaconda.org/conda-forge/osx-64/cffi-2.0.0-py313hf57695f_1.conda - sha256: 16c8c80bebe1c3d671382a64beaa16996e632f5b75963379e2b084eb6bc02053 - md5: b10f64f2e725afc9bf2d9b30eff6d0ea - depends: - - __osx >=10.13 - - libffi >=3.5.2,<3.6.0a0 - - pycparser - - python >=3.13,<3.14.0a0 - - python_abi 3.13.* *_cp313 - license: MIT - license_family: MIT - size: 290946 - timestamp: 1761203173891 -- conda: https://conda.anaconda.org/conda-forge/osx-64/libcxx-22.1.8-h19cb2f5_0.conda - sha256: 57ee997f1f800cf38abc743c0f0a9ddfe6a101c697c35510452ce6f4ddf96361 - md5: 0f600157f28fc7bc9549ecafdfa5bc12 - depends: - - __osx >=11.0 - license: Apache-2.0 WITH LLVM-exception - license_family: Apache - size: 566717 - timestamp: 1781672189697 - conda: https://conda.anaconda.org/conda-forge/osx-64/libexpat-2.8.1-hcc62823_1.conda sha256: 9c96cc05e056e1bba5b545cbbd57b6e01db622dc2c82934caaaa25cfb22fe666 md5: dcfdea7b7013beef0a4d744d776ea38f @@ -1064,6 +830,18 @@ packages: license_family: Apache size: 2775300 timestamp: 1781071391999 +- conda: https://conda.anaconda.org/conda-forge/osx-64/prek-0.5.6-he704061_0.conda + sha256: a8327d33e53849a94f423cacece0821691bd1b17b918677c539346602fa7c161 + md5: 958b54425cf8f6c0edbcd98c8914f263 + depends: + - __osx >=11.0 + constrains: + - __osx >=11.0 + license: MIT + license_family: MIT + run_exports: {} + size: 4962127 + timestamp: 1792492271696 - conda: https://conda.anaconda.org/conda-forge/osx-64/psutil-7.2.2-py313h16366db_0.conda sha256: b50a9d64aabd30c05e405cc1166f21fd7dee8d1b42ef38116701883d3bd4d5fa md5: c8185e1891ace76e565b4c28dd50ed5d @@ -1142,19 +920,6 @@ packages: license_family: BSD size: 3282953 timestamp: 1769460532442 -- conda: https://conda.anaconda.org/conda-forge/osx-64/ukkonen-1.1.0-py313h252b9d7_0.conda - sha256: 201d026c60bbbdd7c9bf9b3c61f807711ba24a9899a1b7f8a978b507d44d7efa - md5: e6ab56e180655e23353afea13caebc44 - depends: - - __osx >=10.13 - - cffi - - libcxx >=19 - - python >=3.13,<3.14.0a0 - - python_abi 3.13.* *_cp313 - license: MIT - license_family: MIT - size: 14202 - timestamp: 1769439075795 - conda: https://conda.anaconda.org/conda-forge/osx-64/yaml-0.2.5-h4132b18_3.conda sha256: a335161bfa57b64e6794c3c354e7d49449b28b8d8a7c4ed02bf04c3f009953f9 md5: a645bb90997d3fc2aea0adf6517059bd @@ -1199,20 +964,6 @@ packages: license_family: BSD size: 124834 timestamp: 1771350416561 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/cffi-2.0.0-py313h224173a_1.conda - sha256: 1fa69651f5e81c25d48ac42064db825ed1a3e53039629db69f86b952f5ce603c - md5: 050374657d1c7a4f2ea443c0d0cbd9a0 - depends: - - __osx >=11.0 - - libffi >=3.5.2,<3.6.0a0 - - pycparser - - python >=3.13,<3.14.0a0 - - python >=3.13,<3.14.0a0 *_cp313 - - python_abi 3.13.* *_cp313 - license: MIT - license_family: MIT - size: 291376 - timestamp: 1761203583358 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-78.3-hef89b57_0.conda sha256: 3a7907a17e9937d3a46dfd41cffaf815abad59a569440d1e25177c15fd0684e5 md5: f1182c91c0de31a7abd40cedf6a5ebef @@ -1222,15 +973,6 @@ packages: license_family: MIT size: 12361647 timestamp: 1773822915649 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-22.1.8-h55c6f16_0.conda - sha256: a2e7abab5add9750fab064c024394de48e49f97631c605ad5db5c8ac3fc769ef - md5: 89f76a2a21a3ec3ec983b5eb237c4113 - depends: - - __osx >=11.0 - license: Apache-2.0 WITH LLVM-exception - license_family: Apache - size: 569349 - timestamp: 1781670209146 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.1-hf6b4638_1.conda sha256: 5af74261101e3c777399c6294b2b5d290e508153268eb2e9ff99c4d69834612f md5: a915151d5d3c5bf039f5ccc8402a436f @@ -1326,6 +1068,18 @@ packages: license_family: Apache size: 3102584 timestamp: 1781069820667 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/prek-0.5.6-hc2d82ec_0.conda + sha256: cdc5a03f831fe73380821344e78e5ae16c21fab30e3b03b5086cf4dc5080f72f + md5: fc244dae46d27b91ca8503910edca686 + depends: + - __osx >=11.0 + constrains: + - __osx >=11.0 + license: MIT + license_family: MIT + run_exports: {} + size: 4518194 + timestamp: 1792492197044 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/psutil-7.2.2-py313h6688731_0.conda sha256: 1d2a6039fb71d61134b1d6816202529f2f6286c83b59bc1491fd288f5c08046e md5: ba2d89e51a855963c767648f44c03871 @@ -1406,20 +1160,6 @@ packages: license_family: BSD size: 3127137 timestamp: 1769460817696 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/ukkonen-1.1.0-py313h5c29297_0.conda - sha256: d28d0242d3fa23784630c775d5b628ce25e2d45f5d3f1cfcdc3815bc954073fa - md5: 43b1eb729bd1cd9ea595548eb8100b65 - depends: - - __osx >=11.0 - - cffi - - libcxx >=19 - - python >=3.13,<3.14.0a0 - - python >=3.13,<3.14.0a0 *_cp313 - - python_abi 3.13.* *_cp313 - license: MIT - license_family: MIT - size: 14773 - timestamp: 1769439197815 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/yaml-0.2.5-h925e9cb_3.conda sha256: b03433b13d89f5567e828ea9f1a7d5c5d697bf374c28a4168d71e9464f5dafac md5: 78a0fe9e9c50d2c381e8ee47e3ea437d @@ -1466,20 +1206,6 @@ packages: license_family: BSD size: 56115 timestamp: 1771350256444 -- conda: https://conda.anaconda.org/conda-forge/win-64/cffi-2.0.0-py313h5ea7bf4_1.conda - sha256: f867a11f42bb64a09b232e3decf10f8a8fe5194d7e3a216c6bac9f40483bd1c6 - md5: 55b44664f66a2caf584d72196aa98af9 - depends: - - pycparser - - python >=3.13,<3.14.0a0 - - python_abi 3.13.* *_cp313 - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - license: MIT - license_family: MIT - size: 292681 - timestamp: 1761203203673 - conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.8.1-hac47afa_1.conda sha256: 1a54d874addda73b6f7164d5f3905821277a1831bcc05edd74b3085391688571 md5: ccc490c81ffe14181861beac0e8f3169 @@ -1581,6 +1307,18 @@ packages: license_family: Apache size: 9414790 timestamp: 1781071745579 +- conda: https://conda.anaconda.org/conda-forge/win-64/prek-0.5.6-h18a1a76_0.conda + sha256: 6c4e466cfb5f9865af8a50f33736bdeb54502c692afcb986db69c0dd6de09dbe + md5: 80a9bb7c0906abd8f89880322934c2ba + depends: + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + license: MIT + license_family: MIT + run_exports: {} + size: 4856185 + timestamp: 1792492182435 - conda: https://conda.anaconda.org/conda-forge/win-64/psutil-7.2.2-py313h5fd188c_0.conda sha256: 3ec3373748f83069bef93b540de416e637ee30231b222d5df8f712e93f2f9195 md5: 761b299a6289c77459defea3563f8fc0 @@ -1677,20 +1415,6 @@ packages: license: LicenseRef-MicrosoftWindowsSDK10 size: 694692 timestamp: 1756385147981 -- conda: https://conda.anaconda.org/conda-forge/win-64/ukkonen-1.1.0-py313hf069bd2_0.conda - sha256: 09f3bb587199361774612f4e70226d8688eda264b452ec401e1ce904633dde43 - md5: bfa075d1cd7bf341b8189af9616ce537 - depends: - - cffi - - python >=3.13,<3.14.0a0 - - python_abi 3.13.* *_cp313 - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - license: MIT - license_family: MIT - size: 18441 - timestamp: 1769438882754 - conda: https://conda.anaconda.org/conda-forge/win-64/vc-14.5-h1b7c187_39.conda sha256: 17693b60cb54f80c60275f003f3bfc1b128af56dbfd65c4fae37c64eeb755ce1 md5: 2eacea63f545b97342da520df6854276 diff --git a/pixi.toml b/pixi.toml index 59e9a30..70b8faf 100644 --- a/pixi.toml +++ b/pixi.toml @@ -8,15 +8,15 @@ version = "0.1.0" [dependencies] # Runtime interpreter for azoidcapp (stdlib only; no extra packages needed). -# Capped at <3.14 to avoid conda-forge pre-commit packaging issues with the -# Python 3.14 free-threaded (3.14t) variant. +# Capped at <3.14 to avoid packaging issues with the Python 3.14 +# free-threaded (3.14t) variant. python = ">=3.9,<3.14" [environments] dev = { features = ["dev"] } [feature.dev.dependencies] -pre-commit = "*" +prek = "*" # Tool versions are pinned so developer environments are deterministic # and stay in lockstep with the .pre-commit-config.yaml hook set # (both locally and in CI via the Lint workflow). @@ -27,10 +27,7 @@ codespell = "==2.4.2" yamllint = "==1.38.0" zizmor = "==1.23.1" mypy = "*" -# identify >= 2.x is required by pre-commit-shfmt v3.12+ which uses the -# 'csh' type tag; conda-forge's pre-commit pulls an older identify. -identify = ">=2.5" [feature.dev.tasks] -lint = "pre-commit run --all-files" -lint-files = "pre-commit run --files" +lint = "prek run --all-files" +lint-files = "prek run --files"